Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d413398b3d | ||
|
|
6be55186b0 | ||
|
|
348c5c1d3c | ||
|
|
411a4cb57c | ||
|
|
6df6306ef0 | ||
|
|
57049f17b0 | ||
|
|
a214a35f4f | ||
|
|
faef6adf94 |
No files matched your search
@@ -17,7 +17,9 @@ from app.crud.biz_no_generator import allocate_next_biz_no
|
||||
from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
BizCollectionHospital,
|
||||
BizCollectionProtocol,
|
||||
BizCollectionRegion,
|
||||
BizPresignOrder,
|
||||
BizPresignOrderSampleType,
|
||||
BizPresignTransfer,
|
||||
@@ -503,9 +505,107 @@ def list_presign_orders(
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
order_to_dict(db, o, tenant_no, with_children=False) for o in rows
|
||||
], int(total)
|
||||
items = [order_to_dict(db, o, tenant_no, with_children=False) for o in rows]
|
||||
attach_presign_ledger_fields(db, items, tenant_no)
|
||||
return items, int(total)
|
||||
|
||||
|
||||
def attach_presign_ledger_fields(
|
||||
db: Session, items: List[Dict[str, Any]], tenant_no: str
|
||||
) -> None:
|
||||
"""补列表导出需要的地区、医院、样本类型和转账时间。含已停用主数据名称。"""
|
||||
if not items:
|
||||
return
|
||||
order_ids = [str(item.get("id") or "") for item in items if item.get("id")]
|
||||
region_ids = {str(item.get("regionId") or "") for item in items if item.get("regionId")}
|
||||
hospital_ids = {
|
||||
str(item.get("hospitalId") or "") for item in items if item.get("hospitalId")
|
||||
}
|
||||
region_names: Dict[str, str] = {}
|
||||
if region_ids:
|
||||
region_names = {
|
||||
str(row.id): str(row.region_name or "").strip()
|
||||
for row in db.query(BizCollectionRegion).filter(
|
||||
BizCollectionRegion.id.in_(region_ids),
|
||||
BizCollectionRegion.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
hospital_names: Dict[str, str] = {}
|
||||
if hospital_ids:
|
||||
hospital_names = {
|
||||
str(row.id): str(row.hospital_name or "").strip()
|
||||
for row in db.query(BizCollectionHospital).filter(
|
||||
BizCollectionHospital.id.in_(hospital_ids),
|
||||
BizCollectionHospital.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
type_ids_by_order: Dict[str, List[str]] = {order_id: [] for order_id in order_ids}
|
||||
if order_ids:
|
||||
links = (
|
||||
db.query(BizPresignOrderSampleType)
|
||||
.filter(
|
||||
BizPresignOrderSampleType.presign_order_id.in_(order_ids),
|
||||
BizPresignOrderSampleType.tenant_no == tenant_no,
|
||||
BizPresignOrderSampleType.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizPresignOrderSampleType.order_num.asc(),
|
||||
BizPresignOrderSampleType.create_time.asc(),
|
||||
BizPresignOrderSampleType.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for link in links:
|
||||
if link.sample_type_id:
|
||||
type_ids_by_order.setdefault(link.presign_order_id, []).append(
|
||||
str(link.sample_type_id)
|
||||
)
|
||||
type_ids = {
|
||||
type_id
|
||||
for order_type_ids in type_ids_by_order.values()
|
||||
for type_id in order_type_ids
|
||||
}
|
||||
type_names: Dict[str, str] = {}
|
||||
if type_ids:
|
||||
type_names = {
|
||||
str(row.id): str(row.type_name or "").strip()
|
||||
for row in db.query(BizCellSampleType).filter(
|
||||
BizCellSampleType.id.in_(type_ids),
|
||||
BizCellSampleType.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
times_by_order: Dict[str, List[str]] = {}
|
||||
if order_ids:
|
||||
transfers = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
BizPresignTransfer.presign_order_id.in_(order_ids),
|
||||
BizPresignTransfer.tenant_no == tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizPresignTransfer.transfer_time.asc(),
|
||||
BizPresignTransfer.create_time.asc(),
|
||||
BizPresignTransfer.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for transfer in transfers:
|
||||
if not transfer.transfer_time:
|
||||
continue
|
||||
times_by_order.setdefault(transfer.presign_order_id, []).append(
|
||||
transfer.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
for item in items:
|
||||
order_id = str(item.get("id") or "")
|
||||
item["regionName"] = region_names.get(str(item.get("regionId") or ""), "")
|
||||
item["hospitalName"] = hospital_names.get(str(item.get("hospitalId") or ""), "")
|
||||
names = [
|
||||
type_names.get(type_id, "")
|
||||
for type_id in type_ids_by_order.get(order_id, [])
|
||||
]
|
||||
item["sampleTypeNames"] = [name for name in names if name]
|
||||
item["transferTimes"] = times_by_order.get(order_id, [])
|
||||
|
||||
|
||||
def get_presign_order(
|
||||
|
||||
@@ -409,6 +409,8 @@ def list_protocol_sample_collections(
|
||||
item["newbornSex"] = (party.newborn_sex or "") if party else ""
|
||||
item["newbornDeliveryTime"] = _fmt_dt(party.newborn_delivery_time) if party else ""
|
||||
item["protocolRemark"] = (proto.remark or "") if proto else ""
|
||||
item["voidType"] = (proto.void_type or "") if proto else ""
|
||||
item["voidDesc"] = (proto.void_desc or "") if proto else ""
|
||||
item["protocolAttachments"] = (proto.protocol_attachments or "") if proto else ""
|
||||
item["consentFormUrls"] = (proto.consent_form_urls or "") if proto else ""
|
||||
item["healthSurveyFormUrls"] = (proto.health_survey_form_urls or "") if proto else ""
|
||||
|
||||
@@ -879,6 +879,20 @@ def list_donations(
|
||||
if t.express_company_id
|
||||
],
|
||||
)
|
||||
from app.crud import sample_freight as sample_freight_svc
|
||||
|
||||
all_list_sample_ids = [
|
||||
s.id
|
||||
for samples in sample_map.values()
|
||||
for s in samples
|
||||
if getattr(s, "id", None)
|
||||
]
|
||||
freight_by_sample = sample_freight_svc.freight_map_for_samples(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION,
|
||||
sample_ids=all_list_sample_ids,
|
||||
)
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
@@ -957,11 +971,13 @@ def list_donations(
|
||||
}
|
||||
sample_items.append(
|
||||
{
|
||||
"id": s.id,
|
||||
"sampleTypeName": type_name,
|
||||
"sampleCode": code,
|
||||
"sampleStatus": (s.sample_status or "").strip(),
|
||||
"labStorageCode": lab_code_map.get(code, "") if code else "",
|
||||
"transport": transport_dict,
|
||||
"freight": freight_by_sample.get(s.id),
|
||||
**sample_special_to_dict(s),
|
||||
**build_sample_item_reception_qc_fields(
|
||||
reception_summary_map.get(code) if code else None,
|
||||
|
||||
@@ -2550,3 +2550,41 @@ process.stdout.write(JSON.stringify([
|
||||
'¥9,999,999,999,999,999.99',
|
||||
'¥9,999,999,999,999,999.99',
|
||||
]
|
||||
|
||||
|
||||
def test_web_presign_list_includes_ledger_fields():
|
||||
from datetime import datetime
|
||||
|
||||
from app.crud.presign_order import list_presign_orders
|
||||
|
||||
db = _session()
|
||||
_add_master_data(db)
|
||||
order = _order('ledger-1', region_id='region-a', sales_user_id='u1')
|
||||
order.hospital_id = 'hospital-a'
|
||||
order.mother_name = '陈影'
|
||||
transfer = _transfer('ledger-transfer', order_id='ledger-1', pay_status='yjf')
|
||||
transfer.transfer_time = datetime(2026, 4, 2, 16, 49, 42)
|
||||
db.add_all(
|
||||
[
|
||||
order,
|
||||
transfer,
|
||||
BizPresignOrderSampleType(
|
||||
id='ledger-type',
|
||||
presign_order_id='ledger-1',
|
||||
sample_type_id='sample-a',
|
||||
tenant_no='tenant-a',
|
||||
create_dept_id='',
|
||||
order_num=1,
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
items, total = list_presign_orders(db, tenant_no='tenant-a', page_size=10)
|
||||
|
||||
assert total == 1
|
||||
assert items[0]['regionName'] == 'A区'
|
||||
assert items[0]['hospitalName'] == 'A医院'
|
||||
assert items[0]['sampleTypeNames'] == ['样本A']
|
||||
assert items[0]['transferTimes'] == ['2026-04-02 16:49:42']
|
||||
@@ -0,0 +1,268 @@
|
||||
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], '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCellStorageLedgerRows', () => {
|
||||
it('places installment amounts and keeps template header width', () => {
|
||||
const flat = {
|
||||
protocolId: 'p1',
|
||||
protocol: {
|
||||
protocolStatus: 'sxz',
|
||||
remark: '分期备注',
|
||||
voidType: '',
|
||||
voidDesc: ''
|
||||
},
|
||||
sampleIndex: 0,
|
||||
isCoStored: false,
|
||||
sampleCollectTime: '2025-09-15 08:51:00',
|
||||
motherName: '郭梦朵',
|
||||
protocolNo: 'ZKZX34000849',
|
||||
sampleTypeName: '围产期造血干细胞',
|
||||
regionName: '亳州区域',
|
||||
hospitalName: '涡阳县人民医院',
|
||||
labStorageCode: 'B0000004',
|
||||
salesUserName: '马丽',
|
||||
regionManagerUserName: '张颖',
|
||||
transportType: 'kdys',
|
||||
paymentSummary: {
|
||||
payMode: 'fq',
|
||||
contractAmount: 22880,
|
||||
receivedAmount: 6480,
|
||||
pendingAmount: 16400,
|
||||
firstPayTransferTime: '2025-09-15 11:13:01',
|
||||
paymentDetails: [
|
||||
{
|
||||
installmentNo: 1,
|
||||
dueAmount: 6480,
|
||||
transferAmount: 6480,
|
||||
installmentDueDate: '20250915',
|
||||
transferTime: '2025-09-15 11:13:01'
|
||||
},
|
||||
{
|
||||
installmentNo: 2,
|
||||
dueAmount: 16400,
|
||||
transferAmount: 0,
|
||||
installmentDueDate: '20251215',
|
||||
transferTime: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
} as unknown as ProtocolAuditFlatRow
|
||||
const [row] = buildCellStorageLedgerRows([flat])
|
||||
assert.equal(row.length, CELL_STORAGE_HEADERS.length)
|
||||
assert.equal(CELL_STORAGE_HEADERS[13], '涡阳县人民医院')
|
||||
assert.equal(row[5], 'B0000004')
|
||||
assert.equal(row[6], '生效中')
|
||||
assert.equal(row[11], '否')
|
||||
assert.equal(row[13], '涡阳县人民医院')
|
||||
assert.equal(row[14], '分期')
|
||||
assert.equal(row[16], '22880')
|
||||
assert.equal(row[17], '6480')
|
||||
assert.equal(row[22], '2025年9月15日11时13分01秒')
|
||||
assert.equal(row[24], '16400')
|
||||
assert.equal(row[25], '16400')
|
||||
assert.equal(row[26], '2025年12月15日')
|
||||
assert.equal(row[50], '马丽')
|
||||
assert.equal(row[52], '快递运输')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildResearchLedgerRows', () => {
|
||||
it('maps donation fields and consent flag', () => {
|
||||
const flat = {
|
||||
protocol: {
|
||||
donationStatus: 'yzf',
|
||||
voidType: 'sysfq',
|
||||
voidDesc: '母血乙肝阳性',
|
||||
monthlyVerifyNo: '202508001'
|
||||
},
|
||||
sampleIndex: 0,
|
||||
sampleCollectTime: '2025-08-29 12:27:00',
|
||||
motherName: '曹陆双',
|
||||
sampleTypeName: '围产期造血干细胞',
|
||||
regionName: '亳州',
|
||||
hospitalName: '涡阳县人民医院',
|
||||
labStorageCode: 'A0000001',
|
||||
salesUserName: '杨莉莉',
|
||||
regionManagerUserName: '张颖',
|
||||
transportType: '',
|
||||
newbornDeliveryTime: '2025-08-29 12:26:00',
|
||||
newbornSex: '0',
|
||||
newbornWeightGram: 3000,
|
||||
deliveryMode: 'sc',
|
||||
birthOrder: 1,
|
||||
maternalBloodCollectTime: '2025-08-29 13:30:00',
|
||||
consentFormUrls: 'http://example/consent',
|
||||
remark: '',
|
||||
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[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('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[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,690 @@
|
||||
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 行号含表头)。 */
|
||||
export function buildFreightMergeRanges(freightIds: string[]): 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: 17, endCol: 17 })
|
||||
merges.push({ startRow, endRow, startCol: 18, endCol: 18 })
|
||||
}
|
||||
index = end
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
export function buildPresignLedgerRows(rows: Row[]): string[][] {
|
||||
return rows.map((row, index) => {
|
||||
const orderDate = row.orderDate
|
||||
const times = Array.isArray(row.transferTimes) ? row.transferTimes : []
|
||||
const received = formatLedgerMoney(row.confirmedDepositAmount)
|
||||
return [
|
||||
String(index + 1),
|
||||
yearText(orderDate, ''),
|
||||
monthText(orderDate),
|
||||
dayText(orderDate),
|
||||
text(row.protocolNo),
|
||||
text(row.motherName),
|
||||
joined(row.sampleTypeNames),
|
||||
text(row.regionName),
|
||||
text(row.hospitalName),
|
||||
payModeText(row.payMode),
|
||||
formatLedgerDateTime(row.sampleCollectTime),
|
||||
formatLedgerMoney(row.contractAmount),
|
||||
received ? `${received}元` : '',
|
||||
times.map((item) => formatLedgerDateTime(item)).filter(Boolean).join(','),
|
||||
formatLedgerDate(row.expectedDeliveryDate),
|
||||
text(row.salesUserName),
|
||||
text(row.regionManagerUserName),
|
||||
text(row.remark),
|
||||
''
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: boolean): string[] {
|
||||
const protocol = row.protocol
|
||||
const summary = (row.paymentSummary || protocol.paymentSummary) as Row | null
|
||||
const details = paymentDetails(summary)
|
||||
const first = installmentGroup(details, 1)
|
||||
const collectTime = row.sampleCollectTime
|
||||
const status = research ? protocol.donationStatus || protocol.status : protocol.protocolStatus
|
||||
const received = summary ? formatLedgerMoney(summary.receivedAmount) : ''
|
||||
const contract = formatLedgerMoney(
|
||||
summary?.contractAmount ?? protocol.protocolTotalAmount
|
||||
)
|
||||
const payMode = text(summary?.payMode)
|
||||
const firstPaidTime = text(summary?.firstPayTransferTime) || first.paidTime
|
||||
const voided = text(status) === 'yzf' || text(status) === 'sdfq'
|
||||
const cells = research ? new Array<string>(RESEARCH_HEADERS.length) : new Array<string>(CELL_STORAGE_HEADERS.length)
|
||||
cells.fill('')
|
||||
if (research) {
|
||||
cells[0] = String(index + 1)
|
||||
cells[1] = yearText(collectTime, '年')
|
||||
cells[2] = monthText(collectTime)
|
||||
cells[3] = dayText(collectTime)
|
||||
cells[4] = text(protocol.monthlyVerifyNo)
|
||||
cells[5] = text(row.labStorageCode)
|
||||
cells[6] = researchStorageStatus(status, protocol.voidType)
|
||||
cells[7] = voidReason(protocol)
|
||||
cells[8] = text(row.motherName)
|
||||
cells[9] = text(row.sampleTypeName)
|
||||
cells[10] = text(row.regionName)
|
||||
cells[11] = text(row.hospitalName)
|
||||
cells[12] = formatLedgerDateTime(row.newbornDeliveryTime)
|
||||
cells[13] = formatLedgerDateTime(collectTime)
|
||||
cells[14] = text(row.salesUserName)
|
||||
cells[15] = text(row.regionManagerUserName)
|
||||
cells[16] = 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[5] = text(row.labStorageCode)
|
||||
cells[6] = statusText(status, protocol.voidType)
|
||||
cells[7] = voidReason(protocol)
|
||||
cells[8] = text(row.motherName)
|
||||
cells[9] = text(row.protocolNo)
|
||||
cells[10] = text(row.sampleTypeName)
|
||||
cells[11] = row.isCoStored ? '是' : '否'
|
||||
cells[12] = text(row.regionName)
|
||||
cells[13] = text(row.hospitalName)
|
||||
cells[14] = payModeText(payMode, text(summary?.payModeLabel))
|
||||
cells[15] = formatLedgerDateTime(collectTime)
|
||||
cells[16] = contract
|
||||
cells[17] = formatLedgerMoney(first.due)
|
||||
cells[18] = received
|
||||
cells[19] = voided ? received : ''
|
||||
cells[21] = text(protocol.remark)
|
||||
cells[22] = formatLedgerDateTime(firstPaidTime)
|
||||
cells[23] = gapText(first.due, first.paid)
|
||||
cells[24] =
|
||||
payMode === 'fq' || payMode === 'djfq'
|
||||
? formatLedgerMoney(summary?.pendingAmount)
|
||||
: contract
|
||||
? '0'
|
||||
: ''
|
||||
let cursor = 25
|
||||
for (const installmentNo of [2, 3, 4, 5, 6]) {
|
||||
const group = installmentGroup(details, installmentNo)
|
||||
cells[cursor++] = formatLedgerMoney(group.due)
|
||||
cells[cursor++] = formatLedgerDate(group.dueDate)
|
||||
cells[cursor++] = formatLedgerMoney(group.paid)
|
||||
cells[cursor++] = formatLedgerDateTime(group.paidTime)
|
||||
cells[cursor++] = gapText(group.due, group.paid)
|
||||
}
|
||||
cells[50] = text(row.salesUserName)
|
||||
cells[51] = text(row.regionManagerUserName)
|
||||
cells[52] = transportText(row)
|
||||
return cells
|
||||
}
|
||||
|
||||
export function buildCellStorageLedgerRows(rows: ProtocolAuditFlatRow[]): string[][] {
|
||||
return rows.map((row, index) => storageLedgerRow(row, index, false))
|
||||
}
|
||||
|
||||
export function buildResearchLedgerRows(rows: ProtocolAuditFlatRow[]): {
|
||||
rows: string[][]
|
||||
merges: LedgerMergeRange[]
|
||||
} {
|
||||
const freightIds = rows.map((row) => text(row.freightId))
|
||||
const built = rows.map((row, index) => storageLedgerRow(row, index, true))
|
||||
const merges = buildFreightMergeRanges(freightIds)
|
||||
// 合并区域内仅保留首行运输/运费,其余清空,避免合并后重复显示
|
||||
for (const merge of merges) {
|
||||
if (merge.startCol !== 17 && merge.startCol !== 18) continue
|
||||
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 { rows: built, merges }
|
||||
}
|
||||
|
||||
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: '',
|
||||
attachmentUrls: 'https://x/b.pdf',
|
||||
remark: '备注一',
|
||||
sampleItems: [{ sampleTypeName: 'NK', sampleCode: 'D2', labStorageCode: 'A1' }]
|
||||
sampleItems: [
|
||||
{
|
||||
sampleTypeName: 'NK',
|
||||
sampleCode: 'D2',
|
||||
labStorageCode: 'A1',
|
||||
freight: {
|
||||
id: 'fr-1',
|
||||
transportModeLabel: '专线车',
|
||||
freightAmountLabel: '100'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
assert.equal(rows[0].monthlyVerifyNo, '202508001')
|
||||
@@ -122,6 +133,9 @@ describe('flattenProtocolAuditRows', () => {
|
||||
assert.equal(rows[0].infectiousDiseaseReportUrls, '')
|
||||
assert.equal(rows[0].attachmentUrls, 'https://x/b.pdf')
|
||||
assert.equal(rows[0].remark, '备注一')
|
||||
assert.equal(rows[0].freightId, 'fr-1')
|
||||
assert.equal(rows[0].freightTransportLabel, '专线车')
|
||||
assert.equal(rows[0].freightAmountLabel, '100')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ export type ProtocolAuditFlatRow = {
|
||||
labStorageCode: string
|
||||
transportType: string
|
||||
transportLabel: string
|
||||
/** 样本运费批次;多样本共用时 id 相同 */
|
||||
freightId: string
|
||||
freightTransportLabel: string
|
||||
freightAmountLabel: string
|
||||
paymentSummary: QcProtocolPaymentSummary | null
|
||||
monthlyVerifyNo: string
|
||||
donationStatus: string
|
||||
@@ -90,7 +94,13 @@ export function flattenProtocolAuditRows(
|
||||
slots.forEach((item, sampleIndex) => {
|
||||
const rec = item || {}
|
||||
const transport = asRecord(rec.transport)
|
||||
const freight = asRecord(rec.freight)
|
||||
const transportType = str(transport?.transportType)
|
||||
const freightAmountLabel = str(freight?.freightAmountLabel)
|
||||
const freightAmount =
|
||||
freight?.freightAmount == null || freight?.freightAmount === ''
|
||||
? ''
|
||||
: String(freight.freightAmount)
|
||||
out.push({
|
||||
rowKey: `${protocolId}|${str(rec.sampleCode) || sampleIndex}`,
|
||||
protocolId,
|
||||
@@ -113,6 +123,9 @@ export function flattenProtocolAuditRows(
|
||||
labStorageCode: str(rec.labStorageCode),
|
||||
transportType,
|
||||
transportLabel: '',
|
||||
freightId: str(freight?.id),
|
||||
freightTransportLabel: str(freight?.transportModeLabel) || str(freight?.transportMode),
|
||||
freightAmountLabel: freightAmountLabel || freightAmount,
|
||||
paymentSummary:
|
||||
protocol.paymentSummary && typeof protocol.paymentSummary === 'object'
|
||||
? (protocol.paymentSummary as QcProtocolPaymentSummary)
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增预签约
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :loading="exporting" @click="handleExport">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +200,15 @@
|
||||
const projectFilter = ref('')
|
||||
const teamFilter = ref('')
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, Search, RefreshLeft, Edit, Delete, Check } from '@element-plus/icons-vue'
|
||||
import { Plus, Search, RefreshLeft, Edit, Delete, Check, Download } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
buildPresignLedgerRows,
|
||||
fetchAllPages,
|
||||
PRESIGN_HEADERS,
|
||||
saveCollectionLedgerWorkbook,
|
||||
prepareCollectionLedgerExport
|
||||
} from '@/utils/collection-ledger-export'
|
||||
import { formatDisplayOrDash } from '@/utils/datetime-display'
|
||||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||||
import { presignOrderStatusLabel, presignOrderStatusTagType } from '@/utils/status-tag-registry'
|
||||
@@ -213,6 +225,7 @@
|
||||
const tableData = ref<Record<string, any>[]>([])
|
||||
const selectedRows = ref<Record<string, any>[]>([])
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<string | undefined>(undefined)
|
||||
@@ -246,6 +259,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await prepareCollectionLedgerExport()
|
||||
const rows = selectedRows.value.length
|
||||
? selectedRows.value
|
||||
: await fetchAllPages((page, pageSize) =>
|
||||
fetchPresignOrderList({
|
||||
page,
|
||||
pageSize,
|
||||
projectId: projectFilter.value || undefined,
|
||||
teamId: teamFilter.value || undefined,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
orderStatus: searchForm.orderStatus || undefined
|
||||
})
|
||||
)
|
||||
if (!rows.length) {
|
||||
ElMessage.warning('没有可导出的数据')
|
||||
return
|
||||
}
|
||||
await saveCollectionLedgerWorkbook(
|
||||
'预签约',
|
||||
PRESIGN_HEADERS,
|
||||
buildPresignLedgerRows(rows),
|
||||
'预签约'
|
||||
)
|
||||
ElMessage.success('导出成功')
|
||||
} catch {
|
||||
ElMessage.error('导出失败,请重试')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :loading="exporting" @click="handleExport">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedProtocolCount" @click="openAuditDialog()">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
审核
|
||||
@@ -422,6 +426,7 @@
|
||||
Delete,
|
||||
Search,
|
||||
RefreshLeft,
|
||||
Download,
|
||||
CircleCheck,
|
||||
CircleClose,
|
||||
Select,
|
||||
@@ -456,6 +461,13 @@
|
||||
formatProtocolDocBatchDeleteConfirm
|
||||
} from '@/utils/protocol-doc-label'
|
||||
import { loadVoidTypeDict } from '@/utils/void-type-dict'
|
||||
import {
|
||||
RESEARCH_HEADERS,
|
||||
buildResearchLedgerRows,
|
||||
fetchAllPages,
|
||||
saveCollectionLedgerWorkbook,
|
||||
prepareCollectionLedgerExport
|
||||
} from '@/utils/collection-ledger-export'
|
||||
import {
|
||||
flattenProtocolAuditRows,
|
||||
uniqueProtocolsFromFlatRows,
|
||||
@@ -476,6 +488,7 @@
|
||||
const sampleCollectTimeRange = ref<[string, string] | null>(null)
|
||||
const tableData = ref<ResearchDonationRow[]>([])
|
||||
const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])
|
||||
const exporting = ref(false)
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
@@ -529,6 +542,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await prepareCollectionLedgerExport()
|
||||
const rows = selectedFlatRows.value.length
|
||||
? selectedFlatRows.value
|
||||
: flattenProtocolAuditRows(
|
||||
await fetchAllPages((page, pageSize) =>
|
||||
fetchResearchDonationList({
|
||||
page,
|
||||
pageSize,
|
||||
projectId: projectFilter.value || undefined,
|
||||
teamId: teamFilter.value || undefined,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
donationStatus: searchForm.donationStatus || undefined,
|
||||
createTimeStart: createTimeRange.value?.[0] || undefined,
|
||||
createTimeEnd: createTimeRange.value?.[1] || undefined,
|
||||
sampleCollectTimeStart: sampleCollectTimeRange.value?.[0] || undefined,
|
||||
sampleCollectTimeEnd: sampleCollectTimeRange.value?.[1] || undefined
|
||||
})
|
||||
)
|
||||
)
|
||||
if (!rows.length) {
|
||||
ElMessage.warning('没有可导出的数据')
|
||||
return
|
||||
}
|
||||
const ledger = buildResearchLedgerRows(rows)
|
||||
await saveCollectionLedgerWorkbook(
|
||||
'科研表',
|
||||
RESEARCH_HEADERS,
|
||||
ledger.rows,
|
||||
'科研表',
|
||||
ledger.merges
|
||||
)
|
||||
ElMessage.success('导出成功')
|
||||
} catch {
|
||||
ElMessage.error('导出失败,请重试')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
|
||||
@@ -79,6 +79,10 @@
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :loading="exporting" @click="handleExport">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
:disabled="selectedProtocolCount !== 1"
|
||||
@@ -396,7 +400,8 @@
|
||||
Switch,
|
||||
Timer,
|
||||
Coin,
|
||||
More
|
||||
More,
|
||||
Download
|
||||
} from '@element-plus/icons-vue'
|
||||
import ProtocolFormDialog from '../protocol/ProtocolFormDialog.vue'
|
||||
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
|
||||
@@ -424,6 +429,13 @@
|
||||
transportTypeLabel
|
||||
} from '@/utils/status-tag-registry'
|
||||
import { loadVoidTypeDict } from '@/utils/void-type-dict'
|
||||
import {
|
||||
CELL_STORAGE_HEADERS,
|
||||
buildCellStorageLedgerRows,
|
||||
fetchAllPages,
|
||||
saveCollectionLedgerWorkbook,
|
||||
prepareCollectionLedgerExport
|
||||
} from '@/utils/collection-ledger-export'
|
||||
import {
|
||||
flattenProtocolAuditRows,
|
||||
uniqueProtocolsFromFlatRows,
|
||||
@@ -442,6 +454,7 @@
|
||||
const tableData = ref<CollectionProtocolRow[]>([])
|
||||
const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const flatRows = computed(() => flattenProtocolAuditRows(tableData.value))
|
||||
@@ -494,6 +507,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
await saveCollectionLedgerWorkbook(
|
||||
'自存表',
|
||||
CELL_STORAGE_HEADERS,
|
||||
buildCellStorageLedgerRows(rows),
|
||||
'自存表'
|
||||
)
|
||||
ElMessage.success('导出成功')
|
||||
} catch {
|
||||
ElMessage.error('导出失败,请重试')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
|
||||
@@ -92,6 +92,15 @@
|
||||
<el-icon><Box /></el-icon>
|
||||
登记入库
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isCellStorage"
|
||||
class="btn-ghost"
|
||||
:loading="exporting"
|
||||
@click="handleExport"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isCellStorage"
|
||||
class="btn-ghost"
|
||||
@@ -473,6 +482,7 @@
|
||||
ChatDotRound,
|
||||
Collection,
|
||||
Document,
|
||||
Download,
|
||||
EditPen,
|
||||
Files,
|
||||
Location,
|
||||
@@ -511,6 +521,13 @@
|
||||
sampleStorageFlagLabel,
|
||||
sampleStorageFlagTagType
|
||||
} from '@/utils/status-tag-registry'
|
||||
import {
|
||||
CUSTOMER_SERVICE_HEADERS,
|
||||
buildCustomerServiceLedgerRows,
|
||||
fetchAllPages,
|
||||
saveCollectionLedgerWorkbook,
|
||||
prepareCollectionLedgerExport
|
||||
} from '@/utils/collection-ledger-export'
|
||||
|
||||
defineOptions({ name: 'UnifiedSampleInfoPage' })
|
||||
|
||||
@@ -533,6 +550,7 @@
|
||||
})
|
||||
const tableData = ref<Record<string, unknown>[]>([])
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const selectedRows = ref<Record<string, unknown>[]>([])
|
||||
const tableRef = ref<{ clearSelection?: () => void } | null>(null)
|
||||
@@ -632,6 +650,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting.value || !isCellStorage.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await prepareCollectionLedgerExport()
|
||||
const rows = selectedRows.value.length
|
||||
? selectedRows.value
|
||||
: await fetchAllPages((page, pageSize) =>
|
||||
fetchProtocolSampleCollectionList({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
collectionFlag: searchForm.collectionFlag || undefined,
|
||||
receptionFlag: searchForm.receptionFlag || undefined,
|
||||
qcFlag: searchForm.qcFlag || undefined,
|
||||
storageFlag: searchForm.storageFlag || undefined,
|
||||
storageCertHandled: searchForm.storageCertHandled || undefined,
|
||||
insuranceHandled: searchForm.insuranceHandled || undefined
|
||||
})
|
||||
)
|
||||
if (!rows.length) {
|
||||
ElMessage.warning('没有可导出的数据')
|
||||
return
|
||||
}
|
||||
await saveCollectionLedgerWorkbook(
|
||||
'客服表',
|
||||
CUSTOMER_SERVICE_HEADERS,
|
||||
buildCustomerServiceLedgerRows(rows),
|
||||
'客服表'
|
||||
)
|
||||
ElMessage.success('导出成功')
|
||||
} catch {
|
||||
ElMessage.error('导出失败,请重试')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
|
||||
Reference in new issue
Block a user