diff --git a/backend/app.pid b/backend/app.pid index eefb32fbf..5c5a5111e 100644 --- a/backend/app.pid +++ b/backend/app.pid @@ -1 +1 @@ -25041 +68508 diff --git a/backend/app/api/collection_protocol.py b/backend/app/api/collection_protocol.py index e65ff1b38..ee1363127 100644 --- a/backend/app/api/collection_protocol.py +++ b/backend/app/api/collection_protocol.py @@ -409,6 +409,133 @@ async def submit_collection_protocol_audit( # --------------------------------------------------------------------------- +@router.get("/protocol-payment-details") +async def list_payment_details( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + payStatus: str = Query("dfj", alias="payStatus"), + keyword: str = Query("", alias="keyword"), + transferType: str = Query("", alias="transferType"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items, total = cp_svc.list_payment_details_page( + db, + page=page, + page_size=pageSize, + pay_status=payStatus, + keyword=keyword or None, + transfer_type=transferType or None, + tenant_no=tenant_no, + ) + tp = cp_svc.total_pages(total, pageSize) + return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) + + +class BatchDetailIdsBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + detail_ids: List[str] = Field(..., alias="detailIds", min_length=1) + + +class RefundFinanceSubmitBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + refund_amount: float = Field(..., alias="refundAmount", gt=0) + refund_remark: Optional[str] = Field(None, alias="refundRemark") + refund_attachments: Optional[str] = Field(None, alias="refundAttachments") + + +class BatchRefundFinanceSubmitBody(RefundFinanceSubmitBody): + model_config = ConfigDict(populate_by_name=True) + + detail_ids: List[str] = Field(..., alias="detailIds", min_length=1) + + +@router.post("/protocol-payment-details/{detail_id}/submit-receipt-finance") +async def post_submit_receipt_finance( + detail_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + cp_svc.submit_receipt_finance( + db, + detail_id=detail_id, + tenant_no=tenant_no, + user_id=user.get("user_id"), + ) + return success_response(None, message="提交成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/protocol-payment-details/submit-receipt-finance-batch") +async def post_submit_receipt_finance_batch( + body: BatchDetailIdsBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + count = cp_svc.batch_submit_receipt_finance( + db, + detail_ids=body.detail_ids, + tenant_no=tenant_no, + user_id=user.get("user_id"), + ) + return success_response({"count": count}, message=f"已成功提交 {count} 条") + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/protocol-payment-details/{detail_id}/submit-refund-finance") +async def post_submit_refund_finance( + detail_id: str, + body: RefundFinanceSubmitBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + cp_svc.submit_refund_finance( + db, + detail_id=detail_id, + tenant_no=tenant_no, + user_id=user.get("user_id"), + refund_amount=body.refund_amount, + refund_remark=body.refund_remark, + refund_attachments=body.refund_attachments, + ) + return success_response(None, message="提交成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/protocol-payment-details/submit-refund-finance-batch") +async def post_submit_refund_finance_batch( + body: BatchRefundFinanceSubmitBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + count = cp_svc.batch_submit_refund_finance( + db, + detail_ids=body.detail_ids, + tenant_no=tenant_no, + user_id=user.get("user_id"), + refund_amount=body.refund_amount, + refund_remark=body.refund_remark, + refund_attachments=body.refund_attachments, + ) + return success_response({"count": count}, message=f"已成功提交 {count} 条") + except ValueError as e: + return error_response(400, str(e)) + + @router.get("/protocol-payment-details/receipt-confirm") async def list_receipt_confirm( page: int = Query(1, ge=1), @@ -422,10 +549,35 @@ async def list_receipt_confirm( return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) +@router.get("/protocol-payment-details/receipt-confirmed") +async def list_receipt_confirmed( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items, total = cp_svc.list_receipt_confirmed_details( + db, page=page, page_size=pageSize, tenant_no=tenant_no + ) + tp = cp_svc.total_pages(total, pageSize) + return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) + + class FinanceConfirmBody(BaseModel): model_config = ConfigDict(populate_by_name=True) finance_confirm_attachments: Optional[str] = Field(None, alias="financeConfirmAttachments") + finance_remark: Optional[str] = Field(None, alias="financeRemark") + + +class RefundFinanceConfirmBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + refund_finance_confirm_attachments: Optional[str] = Field( + None, alias="refundFinanceConfirmAttachments" + ) + refund_finance_remark: Optional[str] = Field(None, alias="refundFinanceRemark") @router.post("/protocol-payment-details/{detail_id}/receipt-confirm") @@ -443,6 +595,7 @@ async def post_receipt_confirm( tenant_no=tenant_no, user_id=user.get("user_id"), finance_confirm_attachments=body.finance_confirm_attachments, + finance_remark=body.finance_remark, ) return success_response(None, message="确认成功") except ValueError as e: @@ -467,10 +620,25 @@ async def list_refund_confirm( return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) +@router.get("/protocol-payment-details/refund-confirmed") +async def list_refund_confirmed( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items, total = cp_svc.list_refund_confirmed_details( + db, page=page, page_size=pageSize, tenant_no=tenant_no + ) + tp = cp_svc.total_pages(total, pageSize) + return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) + + @router.post("/protocol-payment-details/{detail_id}/refund-confirm") async def post_refund_confirm( detail_id: str, - body: FinanceConfirmBody, + body: RefundFinanceConfirmBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): @@ -481,33 +649,10 @@ async def post_refund_confirm( detail_id=detail_id, tenant_no=tenant_no, user_id=user.get("user_id"), - finance_confirm_attachments=body.finance_confirm_attachments, + refund_finance_confirm_attachments=body.refund_finance_confirm_attachments, + refund_finance_remark=body.refund_finance_remark, ) return success_response(None, message="确认成功") except ValueError as e: return error_response(400, str(e)) - -# --------------------------------------------------------------------------- -# 分期预警 -# --------------------------------------------------------------------------- - - -@router.get("/protocol-payment-details/installment-warning") -async def list_installment_warning( - page: int = Query(1, ge=1), - pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), - warningType: str = Query("all", alias="warningType"), - user: dict = Depends(require_admin_or_super_admin), - db: Session = Depends(get_db), -): - tenant_no = _get_tenant_no(db, user) - items, total = cp_svc.list_installment_warning( - db, - page=page, - page_size=pageSize, - warning_type=warningType, - tenant_no=tenant_no, - ) - tp = cp_svc.total_pages(total, pageSize) - return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) diff --git a/backend/app/api/menu.py b/backend/app/api/menu.py index 763ce1472..a21cb6200 100644 --- a/backend/app/api/menu.py +++ b/backend/app/api/menu.py @@ -166,6 +166,13 @@ STATIC_MENU_TREE = [ "component": "/cell-storage/protocol/index", "meta": {"title": "采集协议单", "keepAlive": True}, }, + { + "id": "50000000000000000000000000000007", + "name": "CellStoragePaymentInfo", + "path": "payment-info", + "component": "/cell-storage/payment-info/index", + "meta": {"title": "缴费信息", "keepAlive": True}, + }, { "id": "50000000000000000000000000000003", "name": "CellStorageProtocolAudit", @@ -178,21 +185,14 @@ STATIC_MENU_TREE = [ "name": "CellStoragePaymentConfirm", "path": "payment-confirm", "component": "/cell-storage/payment-confirm/index", - "meta": {"title": "采集协议单收款确认(财务确认)", "keepAlive": True}, + "meta": {"title": "缴费确认(财务)", "keepAlive": True}, }, { "id": "50000000000000000000000000000005", "name": "CellStorageRefundConfirm", "path": "refund-confirm", "component": "/cell-storage/refund-confirm/index", - "meta": {"title": "采集协议单退费确认(财务确认)", "keepAlive": True}, - }, - { - "id": "50000000000000000000000000000006", - "name": "CellStorageInstallmentWarning", - "path": "installment-warning", - "component": "/cell-storage/installment-warning/index", - "meta": {"title": "分期缴费用户预警模块", "keepAlive": True}, + "meta": {"title": "退费确认(财务)", "keepAlive": True}, }, ], }, diff --git a/backend/app/crud/collection_protocol.py b/backend/app/crud/collection_protocol.py index 046dac971..c8d981fbd 100644 --- a/backend/app/crud/collection_protocol.py +++ b/backend/app/crud/collection_protocol.py @@ -178,6 +178,14 @@ def urls_to_attachment_list( return result +def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]: + ids = list({x for x in user_ids if x}) + if not ids: + return {} + rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all() + return {u.id: u.nick_name or "" for u in rows} + + def _protocol_to_dict(p: BizCollectionProtocol) -> Dict[str, Any]: return { "id": p.id, @@ -226,21 +234,39 @@ def _detail_to_dict( *, protocol_no: str = "", collection_protocol_id: str = "", + protocol_status: str = "", + pay_mode: str = "", + mother_name: str = "", + mother_id_card: str = "", + newborn_name: str = "", + newborn_id_card: str = "", ) -> Dict[str, Any]: return { "id": d.id, "paymentId": d.payment_id, "collectionProtocolId": collection_protocol_id, "protocolNo": protocol_no, + "protocolStatus": protocol_status, + "payMode": pay_mode, + "motherName": mother_name, + "motherIdCard": mother_id_card, + "newbornName": newborn_name, + "newbornIdCard": newborn_id_card, "transferType": d.transfer_type or "", "dueAmount": _dec(d.due_amount), "transferAmount": _dec(d.transfer_amount), "transferTime": _fmt_dt(d.transfer_time), "transferAttachments": d.transfer_attachments or "", "financeConfirmAttachments": d.finance_confirm_attachments or "", + "financeRemark": d.finance_remark or "", + "refundFinanceConfirmAttachments": d.refund_finance_confirm_attachments or "", + "refundFinanceRemark": d.refund_finance_remark or "", "payStatus": d.pay_status or "", "installmentNo": d.installment_no, "installmentDueDate": (d.installment_due_date or "").strip() if d.installment_due_date else "", + "refundAmount": _dec(d.refund_amount), + "refundRemark": d.refund_remark or "", + "refundAttachments": d.refund_attachments or "", "tenantNo": d.tenant_no, "orderNum": d.order_num if d.order_num is not None else 0, "remark": d.remark or "", @@ -567,10 +593,39 @@ def get_protocol_detail_bundle( ci = get_collection_info(db, protocol_id, tenant_no) s_rows = list_samples(db, protocol_id, tenant_no) sc = get_storage_cert(db, protocol_id, tenant_no) - out: Dict[str, Any] = {"protocol": _protocol_to_dict(p)} + protocol_dict = _protocol_to_dict(p) + ci_dict = _collection_info_to_dict(ci) if ci else None + user_ids: List[str] = [] + if protocol_dict.get("collectorUserId"): + user_ids.append(str(protocol_dict["collectorUserId"])) + if ci_dict and ci_dict.get("salesUserId"): + user_ids.append(str(ci_dict["salesUserId"])) + user_name_map = _user_nick_name_map(db, user_ids) + protocol_dict["collectorUserName"] = user_name_map.get( + str(protocol_dict.get("collectorUserId") or ""), "" + ) + if ci_dict: + ci_dict["salesUserName"] = user_name_map.get(str(ci_dict.get("salesUserId") or ""), "") + region_id = str(ci_dict.get("regionId") or "") + if region_id: + r = ( + db.query(BizCollectionRegion.region_name) + .filter(BizCollectionRegion.id == region_id) + .first() + ) + ci_dict["regionName"] = r.region_name if r else "" + hospital_id = str(ci_dict.get("hospitalId") or "") + if hospital_id: + h = ( + db.query(BizCollectionHospital.hospital_name) + .filter(BizCollectionHospital.id == hospital_id) + .first() + ) + ci_dict["hospitalName"] = h.hospital_name if h else "" + out: Dict[str, Any] = {"protocol": protocol_dict} out["payment"] = _payment_to_dict(pay) if pay else None out["partyCustomer"] = _party_customer_to_dict(pc) if pc else None - out["collectionInfo"] = _collection_info_to_dict(ci) if ci else None + out["collectionInfo"] = ci_dict transport_rows = list_sample_transports(db, protocol_id, tenant_no) transport_by_sample = {t.sample_id: _transport_to_dict(t) for t in transport_rows} samples_out: List[Dict[str, Any]] = [] @@ -591,6 +646,15 @@ def get_protocol_detail_bundle( collection_protocol_id=protocol_id, ) item["attachments"] = urls_to_attachment_list(d.transfer_attachments, db, tenant_no) + item["financeConfirmAttachmentFiles"] = urls_to_attachment_list( + d.finance_confirm_attachments, db, tenant_no + ) + item["refundFinanceConfirmAttachmentFiles"] = urls_to_attachment_list( + d.refund_finance_confirm_attachments, db, tenant_no + ) + item["refundAttachmentFiles"] = urls_to_attachment_list( + d.refund_attachments, db, tenant_no + ) details_out.append(item) out["paymentDetails"] = details_out else: @@ -697,7 +761,7 @@ def _detail_query_with_protocol( pay_status_filter: str, ): return ( - db.query(BizProtocolPaymentDetail, BizProtocolPayment, BizCollectionProtocol.protocol_no) + db.query(BizProtocolPaymentDetail, BizProtocolPayment, BizCollectionProtocol) .join(BizProtocolPayment, BizProtocolPaymentDetail.payment_id == BizProtocolPayment.id) .join(BizCollectionProtocol, BizProtocolPayment.collection_protocol_id == BizCollectionProtocol.id) .filter( @@ -710,6 +774,40 @@ def _detail_query_with_protocol( ) +def _rows_to_payment_detail_dicts( + db: Session, + rows: List[Any], + tenant_no: str, + *, + post_process: Optional[Any] = None, +) -> List[Dict[str, Any]]: + """缴费明细行 → 前端字典(含协议、客户、转账附件)。""" + protocol_ids = [ + pay.collection_protocol_id for _, pay, _ in rows if pay and pay.collection_protocol_id + ] + pc_map = _party_customer_map_by_protocol_ids(db, protocol_ids) + out: List[Dict[str, Any]] = [] + for d, pay, prot in rows: + cpid = pay.collection_protocol_id if pay else "" + pc = pc_map.get(cpid) + item = _detail_to_dict( + d, + protocol_no=(prot.protocol_no if prot else "") or "", + collection_protocol_id=cpid, + protocol_status=(prot.protocol_status if prot else "") or "", + pay_mode=(pay.pay_mode if pay else "") or "", + mother_name=(pc.mother_name if pc else "") or "", + mother_id_card=(pc.mother_id_card if pc else "") or "", + newborn_name=(pc.newborn_name if pc else "") or "", + newborn_id_card=(pc.newborn_id_card if pc else "") or "", + ) + item["attachments"] = urls_to_attachment_list(d.transfer_attachments, db, tenant_no) + if post_process: + post_process(item, d) + out.append(item) + return out + + def list_receipt_confirm_details( db: Session, *, @@ -729,15 +827,39 @@ def list_receipt_confirm_details( .limit(page_size) .all() ) - out = [ - _detail_to_dict( - d, - protocol_no=pn or "", - collection_protocol_id=pay.collection_protocol_id if pay else "", + return _rows_to_payment_detail_dicts(db, rows, tenant_no), int(total) + + +def list_receipt_confirmed_details( + db: Session, + *, + page: int = 1, + page_size: int = 10, + tenant_no: Optional[str] = None, +) -> Tuple[List[Dict[str, Any]], int]: + """财务已确认收款(yjf),按确认时间倒序。""" + if not tenant_no: + return [], 0 + q_base = _detail_query_with_protocol(db, tenant_no, "yjf") + total = q_base.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = ( + q_base.order_by(BizProtocolPaymentDetail.update_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + def _add_finance_files(item: Dict[str, Any], d: BizProtocolPaymentDetail) -> None: + item["financeConfirmAttachmentFiles"] = urls_to_attachment_list( + d.finance_confirm_attachments, db, tenant_no ) - for d, pay, pn in rows - ] - return out, int(total) + + return ( + _rows_to_payment_detail_dicts(db, rows, tenant_no, post_process=_add_finance_files), + int(total), + ) def confirm_receipt( @@ -747,6 +869,7 @@ def confirm_receipt( tenant_no: str, user_id: Optional[str], finance_confirm_attachments: Optional[str] = None, + finance_remark: Optional[str] = None, ) -> BizProtocolPaymentDetail: d = ( db.query(BizProtocolPaymentDetail) @@ -768,6 +891,8 @@ def confirm_receipt( d.pay_status = "yjf" if finance_confirm_attachments is not None: d.finance_confirm_attachments = finance_confirm_attachments.strip() or None + if finance_remark is not None: + d.finance_remark = finance_remark.strip() or None d.update_by = user_id d.update_time = datetime.now() db.add(d) @@ -795,15 +920,49 @@ def list_refund_confirm_details( .limit(page_size) .all() ) - out = [ - _detail_to_dict( - d, - protocol_no=pn or "", - collection_protocol_id=pay.collection_protocol_id if pay else "", + def _add_refund_apply_files(item: Dict[str, Any], d: BizProtocolPaymentDetail) -> None: + item["refundAttachmentFiles"] = urls_to_attachment_list( + d.refund_attachments, db, tenant_no ) - for d, pay, pn in rows - ] - return out, int(total) + + return ( + _rows_to_payment_detail_dicts(db, rows, tenant_no, post_process=_add_refund_apply_files), + int(total), + ) + + +def list_refund_confirmed_details( + db: Session, + *, + page: int = 1, + page_size: int = 10, + tenant_no: Optional[str] = None, +) -> Tuple[List[Dict[str, Any]], int]: + """财务已确认退费(ytf),按确认时间倒序。""" + if not tenant_no: + return [], 0 + q_base = _detail_query_with_protocol(db, tenant_no, "ytf") + total = q_base.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = ( + q_base.order_by(BizProtocolPaymentDetail.update_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + def _add_refund_confirmed_files(item: Dict[str, Any], d: BizProtocolPaymentDetail) -> None: + item["refundAttachmentFiles"] = urls_to_attachment_list( + d.refund_attachments, db, tenant_no + ) + item["refundFinanceConfirmAttachmentFiles"] = urls_to_attachment_list( + d.refund_finance_confirm_attachments, db, tenant_no + ) + + return ( + _rows_to_payment_detail_dicts(db, rows, tenant_no, post_process=_add_refund_confirmed_files), + int(total), + ) def confirm_refund( @@ -812,7 +971,8 @@ def confirm_refund( detail_id: str, tenant_no: str, user_id: Optional[str], - finance_confirm_attachments: Optional[str] = None, + refund_finance_confirm_attachments: Optional[str] = None, + refund_finance_remark: Optional[str] = None, ) -> BizProtocolPaymentDetail: d = ( db.query(BizProtocolPaymentDetail) @@ -832,8 +992,10 @@ def confirm_refund( raise ValueError("该明细已确认退费") raise ValueError("当前状态不可确认退费") d.pay_status = "ytf" - if finance_confirm_attachments is not None: - d.finance_confirm_attachments = finance_confirm_attachments.strip() or None + if refund_finance_confirm_attachments is not None: + d.refund_finance_confirm_attachments = refund_finance_confirm_attachments.strip() or None + if refund_finance_remark is not None: + d.refund_finance_remark = refund_finance_remark.strip() or None d.update_by = user_id d.update_time = datetime.now() db.add(d) @@ -842,6 +1004,224 @@ def confirm_refund( return d +def _get_payment_detail_or_raise( + db: Session, + *, + detail_id: str, + tenant_no: str, +) -> BizProtocolPaymentDetail: + d = ( + db.query(BizProtocolPaymentDetail) + .filter( + and_( + BizProtocolPaymentDetail.id == detail_id, + BizProtocolPaymentDetail.tenant_no == tenant_no, + BizProtocolPaymentDetail.del_flag == 0, + ) + ) + .first() + ) + if not d: + raise ValueError("缴费明细不存在") + return d + + +def _apply_submit_receipt_finance( + d: BizProtocolPaymentDetail, + *, + user_id: Optional[str], +) -> None: + if d.pay_status != "dfj": + raise ValueError("仅待缴费状态可提交财务确认") + d.pay_status = "jfqrz" + d.update_by = user_id + d.update_time = datetime.now() + + +def submit_receipt_finance( + db: Session, + *, + detail_id: str, + tenant_no: str, + user_id: Optional[str], +) -> BizProtocolPaymentDetail: + d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no) + _apply_submit_receipt_finance(d, user_id=user_id) + db.add(d) + db.commit() + db.refresh(d) + return d + + +def batch_submit_receipt_finance( + db: Session, + *, + detail_ids: List[str], + tenant_no: str, + user_id: Optional[str], +) -> int: + ids = [str(i).strip() for i in (detail_ids or []) if str(i).strip()] + if not ids: + raise ValueError("请选择缴费明细") + for detail_id in ids: + d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no) + _apply_submit_receipt_finance(d, user_id=user_id) + db.add(d) + db.commit() + return len(ids) + + +def _apply_submit_refund_finance( + d: BizProtocolPaymentDetail, + *, + user_id: Optional[str], + refund_amount: Any, + refund_remark: Optional[str] = None, + refund_attachments: Optional[str] = None, +) -> None: + if d.pay_status != "yjf": + raise ValueError("仅已缴费状态可提交退费财务确认") + if refund_amount is None or Decimal(str(refund_amount)) <= 0: + raise ValueError("请填写有效的退费金额") + d.pay_status = "tfqrz" + d.refund_amount = Decimal(str(refund_amount)) + d.refund_remark = (refund_remark or "").strip() or None + d.refund_attachments = (refund_attachments or "").strip() or None + d.update_by = user_id + d.update_time = datetime.now() + + +def submit_refund_finance( + db: Session, + *, + detail_id: str, + tenant_no: str, + user_id: Optional[str], + refund_amount: Any, + refund_remark: Optional[str] = None, + refund_attachments: Optional[str] = None, +) -> BizProtocolPaymentDetail: + d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no) + _apply_submit_refund_finance( + d, + user_id=user_id, + refund_amount=refund_amount, + refund_remark=refund_remark, + refund_attachments=refund_attachments, + ) + db.add(d) + db.commit() + db.refresh(d) + return d + + +def batch_submit_refund_finance( + db: Session, + *, + detail_ids: List[str], + tenant_no: str, + user_id: Optional[str], + refund_amount: Any, + refund_remark: Optional[str] = None, + refund_attachments: Optional[str] = None, +) -> int: + ids = [str(i).strip() for i in (detail_ids or []) if str(i).strip()] + if not ids: + raise ValueError("请选择缴费明细") + for detail_id in ids: + d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no) + _apply_submit_refund_finance( + d, + user_id=user_id, + refund_amount=refund_amount, + refund_remark=refund_remark, + refund_attachments=refund_attachments, + ) + db.add(d) + db.commit() + return len(ids) + + +def _party_customer_map_by_protocol_ids( + db: Session, + protocol_ids: List[str], +) -> Dict[str, BizProtocolPartyCustomer]: + if not protocol_ids: + return {} + rows = ( + db.query(BizProtocolPartyCustomer) + .filter( + BizProtocolPartyCustomer.collection_protocol_id.in_(protocol_ids), + BizProtocolPartyCustomer.del_flag == 0, + ) + .all() + ) + return {r.collection_protocol_id: r for r in rows} + + +def list_payment_details_page( + db: Session, + *, + page: int = 1, + page_size: int = 10, + pay_status: Optional[str] = None, + keyword: Optional[str] = None, + transfer_type: Optional[str] = None, + tenant_no: Optional[str] = None, +) -> Tuple[List[Dict[str, Any]], int]: + if not tenant_no: + return [], 0 + q = ( + db.query(BizProtocolPaymentDetail, BizProtocolPayment, BizCollectionProtocol) + .join(BizProtocolPayment, BizProtocolPaymentDetail.payment_id == BizProtocolPayment.id) + .join(BizCollectionProtocol, BizProtocolPayment.collection_protocol_id == BizCollectionProtocol.id) + .filter( + BizProtocolPaymentDetail.del_flag == 0, + BizProtocolPayment.del_flag == 0, + BizCollectionProtocol.del_flag == 0, + BizProtocolPaymentDetail.tenant_no == tenant_no, + ) + ) + if pay_status: + q = q.filter(BizProtocolPaymentDetail.pay_status == pay_status) + if keyword and keyword.strip(): + like = f"%{keyword.strip()}%" + q = q.filter(BizCollectionProtocol.protocol_no.like(like)) + if transfer_type and transfer_type.strip(): + q = q.filter(BizProtocolPaymentDetail.transfer_type == transfer_type.strip()) + total = q.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = ( + q.order_by(BizProtocolPaymentDetail.installment_due_date.asc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + protocol_ids = [ + pay.collection_protocol_id for _, pay, _ in rows if pay and pay.collection_protocol_id + ] + pc_map = _party_customer_map_by_protocol_ids(db, protocol_ids) + out = [] + for d, pay, prot in rows: + cpid = pay.collection_protocol_id if pay else "" + pc = pc_map.get(cpid) + out.append( + _detail_to_dict( + d, + protocol_no=(prot.protocol_no if prot else "") or "", + collection_protocol_id=cpid, + protocol_status=(prot.protocol_status if prot else "") or "", + pay_mode=(pay.pay_mode if pay else "") or "", + mother_name=(pc.mother_name if pc else "") or "", + mother_id_card=(pc.mother_id_card if pc else "") or "", + newborn_name=(pc.newborn_name if pc else "") or "", + newborn_id_card=(pc.newborn_id_card if pc else "") or "", + ) + ) + return out, int(total) + + def _classify_installment_warning(due: str, today: str) -> str: """overdue | warn | upcoming — due 为 YYYYMMDD""" if not due or len(due) != 8 or not due.isdigit(): @@ -872,13 +1252,16 @@ def list_installment_warning( # 拉取当前租户下候选(分期号非空);日期过滤在内存中完成以保证与 classify 一致 rows_all = q0.order_by(BizProtocolPaymentDetail.installment_due_date.asc()).all() flattened: List[Dict[str, Any]] = [] - for d, pay, pn in rows_all: + for d, pay, prot in rows_all: due_raw = (d.installment_due_date or "").strip() if d.installment_due_date else "" wt = _classify_installment_warning(due_raw, today) + cpid = pay.collection_protocol_id if pay else "" item = _detail_to_dict( d, - protocol_no=pn or "", - collection_protocol_id=pay.collection_protocol_id if pay else "", + protocol_no=(prot.protocol_no if prot else "") or "", + collection_protocol_id=cpid, + protocol_status=(prot.protocol_status if prot else "") or "", + pay_mode=(pay.pay_mode if pay else "") or "", ) item["warningType"] = wt wt_filter = (warning_type or "all").strip().lower() diff --git a/backend/app/models/models.py b/backend/app/models/models.py index a1cc113de..c1a138c61 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -796,11 +796,17 @@ class BizProtocolPaymentDetail(Base): due_amount = Column(Numeric(18, 2), nullable=True, comment="应缴费金额") transfer_amount = Column(Numeric(18, 2), nullable=True, comment="转账金额") transfer_time = Column(DateTime, nullable=True, comment="转账时间") - transfer_attachments = Column(String(4000), nullable=True, comment="转账附件") - finance_confirm_attachments = Column(String(4000), nullable=True, comment="财务确认附件") + transfer_attachments = Column(Text, nullable=True, comment="转账附件") + finance_confirm_attachments = Column(Text, nullable=True, comment="缴费财务确认附件") + finance_remark = Column(String(512), nullable=True, comment="缴费财务确认备注") + refund_finance_confirm_attachments = Column(Text, nullable=True, comment="财务退费确认附件") + refund_finance_remark = Column(String(512), nullable=True, comment="财务退费确认备注") pay_status = Column(String(32), nullable=False, default="dfj", comment="缴费状态") installment_no = Column(Integer, nullable=True, comment="分期期序号") installment_due_date = Column(CHAR(8), nullable=True, comment="分期应缴日期YYYYMMDD") + refund_amount = Column(Numeric(18, 2), nullable=True, comment="退费金额") + refund_remark = Column(String(512), nullable=True, comment="退费备注") + refund_attachments = Column(Text, nullable=True, comment="退费附件逗号分隔") tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") order_num = Column(Integer, nullable=True, comment="排序号") remark = Column(String(512), nullable=True, comment="备注") diff --git a/backend/tests/test_collection_protocol_conventions.py b/backend/tests/test_collection_protocol_conventions.py index 95efb078b..00759de43 100644 --- a/backend/tests/test_collection_protocol_conventions.py +++ b/backend/tests/test_collection_protocol_conventions.py @@ -195,6 +195,60 @@ class TestListReceiptConfirmSortOrder(unittest.TestCase): ) +class TestListReceiptConfirmedSortOrder(unittest.TestCase): + """list_receipt_confirmed_details should sort by update_time DESC.""" + + @patch("app.crud.collection_protocol.BizProtocolPaymentDetail") + @patch("app.crud.collection_protocol.BizProtocolPayment") + @patch("app.crud.collection_protocol.BizCollectionProtocol") + def test_receipt_confirmed_sorts_by_update_time_desc(self, MockProtocol, MockPayment, MockDetail): + from app.crud.collection_protocol import list_receipt_confirmed_details + + mock_db = MagicMock() + mock_query = MagicMock() + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.with_entities.return_value = mock_query + mock_query.scalar.return_value = 0 + mock_query.offset.return_value = mock_query + mock_query.limit.return_value = mock_query + mock_query.all.return_value = [] + mock_db.query.return_value = mock_query + + list_receipt_confirmed_details(mock_db, tenant_no="t1") + + order_by_calls = mock_query.order_by.call_args_list + self.assertGreaterEqual(len(order_by_calls), 1) + + +class TestListRefundConfirmedSortOrder(unittest.TestCase): + """list_refund_confirmed_details should sort by update_time DESC.""" + + @patch("app.crud.collection_protocol.BizProtocolPaymentDetail") + @patch("app.crud.collection_protocol.BizProtocolPayment") + @patch("app.crud.collection_protocol.BizCollectionProtocol") + def test_refund_confirmed_sorts_by_update_time_desc(self, MockProtocol, MockPayment, MockDetail): + from app.crud.collection_protocol import list_refund_confirmed_details + + mock_db = MagicMock() + mock_query = MagicMock() + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.with_entities.return_value = mock_query + mock_query.scalar.return_value = 0 + mock_query.offset.return_value = mock_query + mock_query.limit.return_value = mock_query + mock_query.all.return_value = [] + mock_db.query.return_value = mock_query + + list_refund_confirmed_details(mock_db, tenant_no="t1") + + order_by_calls = mock_query.order_by.call_args_list + self.assertGreaterEqual(len(order_by_calls), 1) + + # --------------------------------------------------------------------------- # M1: Sort order — list_refund_confirm_details should include order_num ASC # --------------------------------------------------------------------------- diff --git a/docs/sql/alter-biz-protocol-party-customer-apgar-decimal.sql b/docs/sql/alter-biz-protocol-party-customer-apgar-decimal.sql deleted file mode 100644 index 030d4fb24..000000000 --- a/docs/sql/alter-biz-protocol-party-customer-apgar-decimal.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 采集协议单当事人:新生儿 Apgar 评分支持一位小数 -ALTER TABLE `biz_protocol_party_customer` - MODIFY COLUMN `apgar_1min` decimal(4,1) DEFAULT NULL COMMENT '新生儿评分1分钟', - MODIFY COLUMN `apgar_5min` decimal(4,1) DEFAULT NULL COMMENT '新生儿评分5分钟'; diff --git a/docs/sql/alter-biz-protocol-sample-status-nullable.sql b/docs/sql/alter-biz-protocol-sample-status-nullable.sql deleted file mode 100644 index bacaea3f4..000000000 --- a/docs/sql/alter-biz-protocol-sample-status-nullable.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 样本状态允许为空,移除默认「已采集」,与前端清空行为一致 -ALTER TABLE `biz_protocol_sample` - MODIFY COLUMN `sample_status` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'ycj已采集 jcz检测中 yrk已入库 yzf已作废'; diff --git a/docs/sql/zkxw.sql b/docs/sql/zkxw.sql index fe805ed36..410387545 100644 --- a/docs/sql/zkxw.sql +++ b/docs/sql/zkxw.sql @@ -1474,11 +1474,17 @@ CREATE TABLE `biz_protocol_payment_detail` ( `due_amount` decimal(18,2) DEFAULT NULL COMMENT '应缴费金额', `transfer_amount` decimal(18,2) DEFAULT NULL COMMENT '转账金额', `transfer_time` datetime DEFAULT NULL COMMENT '转账时间', - `transfer_attachments` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '转账附件逗号分隔', - `finance_confirm_attachments` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '财务确认附件逗号分隔', + `transfer_attachments` text COLLATE utf8mb4_unicode_ci COMMENT '转账附件逗号分隔', + `finance_confirm_attachments` text COLLATE utf8mb4_unicode_ci COMMENT '缴费财务确认附件逗号分隔', + `finance_remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '缴费财务确认备注', + `refund_finance_confirm_attachments` text COLLATE utf8mb4_unicode_ci COMMENT '财务退费确认附件逗号分隔', + `refund_finance_remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '财务退费确认备注', `pay_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'dfj' COMMENT 'dfj待缴费 jfqrz缴费确认中 yjf已缴费 tfqrz退费确认中 ytf已退费', `installment_no` int(11) DEFAULT NULL COMMENT '分期期序号,趸交为空', `installment_due_date` char(8) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '分期应缴费时间YYYYMMDD', + `refund_amount` decimal(18,2) DEFAULT NULL COMMENT '退费金额', + `refund_remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '退费备注', + `refund_attachments` text COLLATE utf8mb4_unicode_ci COMMENT '退费附件逗号分隔URL', `tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号', `order_num` int(11) DEFAULT NULL COMMENT '排序号', `remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', diff --git a/web-admin/src/App.vue b/web-admin/src/App.vue index 2d83064c1..570eb4cca 100644 --- a/web-admin/src/App.vue +++ b/web-admin/src/App.vue @@ -56,6 +56,8 @@ const buttons = document.querySelectorAll(selectors.join(',')) buttons.forEach((button) => { autoIconClassList.forEach((className) => button.classList.remove(className)) + // 区域/状态 Tab 仅展示文案,不参与按钮自动图标 + if (button.classList.contains('region-tab-btn')) return const text = (button.textContent || '').replace(/\s+/g, '') const matched = iconClassByText.find(([rule]) => rule.test(text)) if (matched) button.classList.add(matched[1]) @@ -78,7 +80,7 @@ const panelTitleClassList = panelTitleIconByText.map(([, cls]) => cls) const panelTitleBarClass = 'title-bar-heading' const panelTitleBarTextRules: RegExp[] = [ - /(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|收款确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表)/ + /(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表)/ ] const refreshPanelTitleIcons = () => { diff --git a/web-admin/src/api/collection-protocol.ts b/web-admin/src/api/collection-protocol.ts index 0742a180d..8e69fd264 100644 --- a/web-admin/src/api/collection-protocol.ts +++ b/web-admin/src/api/collection-protocol.ts @@ -68,9 +68,16 @@ export function fetchReceiptConfirmList(params?: { page?: number; pageSize?: num }) } +export function fetchReceiptConfirmedList(params?: { page?: number; pageSize?: number }) { + return request.get>>({ + url: '/web/admin/protocol-payment-details/receipt-confirmed', + params + }) +} + export function fetchReceiptConfirmSubmit( detailId: string, - data?: { financeConfirmAttachments?: string } + data?: { financeConfirmAttachments?: string; financeRemark?: string } ) { return request.post({ url: `/web/admin/protocol-payment-details/${detailId}/receipt-confirm`, @@ -86,9 +93,16 @@ export function fetchRefundConfirmList(params?: { page?: number; pageSize?: numb }) } +export function fetchRefundConfirmedList(params?: { page?: number; pageSize?: number }) { + return request.get>>({ + url: '/web/admin/protocol-payment-details/refund-confirmed', + params + }) +} + export function fetchRefundConfirmSubmit( detailId: string, - data?: { financeConfirmAttachments?: string } + data?: { refundFinanceConfirmAttachments?: string; refundFinanceRemark?: string } ) { return request.post({ url: `/web/admin/protocol-payment-details/${detailId}/refund-confirm`, @@ -97,13 +111,58 @@ export function fetchRefundConfirmSubmit( }) } -export function fetchInstallmentWarningList(params?: { +export function fetchSubmitReceiptFinance(detailId: string) { + return request.post({ + url: `/web/admin/protocol-payment-details/${detailId}/submit-receipt-finance`, + showSuccessMessage: true + }) +} + +export function fetchSubmitReceiptFinanceBatch(detailIds: string[]) { + return request.post({ + url: '/web/admin/protocol-payment-details/submit-receipt-finance-batch', + data: { detailIds }, + showSuccessMessage: true + }) +} + +export function fetchSubmitRefundFinance( + detailId: string, + data: { + refundAmount: number + refundRemark?: string + refundAttachments?: string + } +) { + return request.post({ + url: `/web/admin/protocol-payment-details/${detailId}/submit-refund-finance`, + data, + showSuccessMessage: true + }) +} + +export function fetchSubmitRefundFinanceBatch(data: { + detailIds: string[] + refundAmount: number + refundRemark?: string + refundAttachments?: string +}) { + return request.post({ + url: '/web/admin/protocol-payment-details/submit-refund-finance-batch', + data, + showSuccessMessage: true + }) +} + +export function fetchPaymentDetailList(params?: { page?: number pageSize?: number - warningType?: string + payStatus?: string + keyword?: string + transferType?: string }) { return request.get>>({ - url: '/web/admin/protocol-payment-details/installment-warning', + url: '/web/admin/protocol-payment-details', params }) } diff --git a/web-admin/src/components/business/protocol-finance-confirm-fields/finance-attachments.ts b/web-admin/src/components/business/protocol-finance-confirm-fields/finance-attachments.ts new file mode 100644 index 000000000..b5bebd26e --- /dev/null +++ b/web-admin/src/components/business/protocol-finance-confirm-fields/finance-attachments.ts @@ -0,0 +1,14 @@ +export type FinanceAttachmentItem = { + id: string + fileName: string + fileUrl: string + fileType?: string + _uploading?: boolean +} + +export function financeAttachmentsToUrlString(items: FinanceAttachmentItem[]) { + return items + .filter((f) => f.fileUrl && !f._uploading) + .map((f) => f.fileUrl) + .join(',') +} diff --git a/web-admin/src/components/business/protocol-finance-confirm-fields/index.vue b/web-admin/src/components/business/protocol-finance-confirm-fields/index.vue new file mode 100644 index 000000000..cc25c843a --- /dev/null +++ b/web-admin/src/components/business/protocol-finance-confirm-fields/index.vue @@ -0,0 +1,320 @@ + + + + + + diff --git a/web-admin/src/components/business/protocol-payment-detail-readonly/index.vue b/web-admin/src/components/business/protocol-payment-detail-readonly/index.vue new file mode 100644 index 000000000..4a4a2e7d5 --- /dev/null +++ b/web-admin/src/components/business/protocol-payment-detail-readonly/index.vue @@ -0,0 +1,253 @@ + + + + + + diff --git a/web-admin/src/router/modules/sqxp.ts b/web-admin/src/router/modules/sqxp.ts index 05d8ef352..1d188d8a9 100644 --- a/web-admin/src/router/modules/sqxp.ts +++ b/web-admin/src/router/modules/sqxp.ts @@ -129,6 +129,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [ component: '/cell-storage/protocol/index', meta: { title: '采集协议单', keepAlive: true } }, + { + id: '50000000000000000000000000000007', + name: 'CellStoragePaymentInfo', + path: 'payment-info', + component: '/cell-storage/payment-info/index', + meta: { title: '缴费信息', keepAlive: true } + }, { id: '50000000000000000000000000000003', name: 'CellStorageProtocolAudit', @@ -141,7 +148,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [ name: 'CellStoragePaymentConfirm', path: 'payment-confirm', component: '/cell-storage/payment-confirm/index', - meta: { title: '收款确认(财务)', keepAlive: true } + meta: { title: '缴费确认(财务)', keepAlive: true } }, { id: '50000000000000000000000000000005', @@ -149,13 +156,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [ path: 'refund-confirm', component: '/cell-storage/refund-confirm/index', meta: { title: '退费确认(财务)', keepAlive: true } - }, - { - id: '50000000000000000000000000000006', - name: 'CellStorageInstallmentWarning', - path: 'installment-warning', - component: '/cell-storage/installment-warning/index', - meta: { title: '分期缴费预警', keepAlive: true } } ] }, diff --git a/web-admin/src/utils/mask-sensitive.ts b/web-admin/src/utils/mask-sensitive.ts new file mode 100644 index 000000000..45736c49b --- /dev/null +++ b/web-admin/src/utils/mask-sensitive.ts @@ -0,0 +1,18 @@ +/** 身份证号脱敏:18 位保留前 6 后 4,15 位保留前 6 后 3 */ +export function maskIdCard(value: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '-' + const len = raw.length + if (len === 18) { + return `${raw.slice(0, 6)}${'*'.repeat(8)}${raw.slice(14)}` + } + if (len === 15) { + return `${raw.slice(0, 6)}${'*'.repeat(6)}${raw.slice(12)}` + } + if (len <= 4) { + return '*'.repeat(len) + } + const head = Math.min(3, Math.floor(len / 3)) + const tail = Math.min(4, len - head - 1) + return `${raw.slice(0, head)}${'*'.repeat(len - head - tail)}${raw.slice(len - tail)}` +} diff --git a/web-admin/src/utils/protocol-payment-labels.ts b/web-admin/src/utils/protocol-payment-labels.ts new file mode 100644 index 000000000..35795aa49 --- /dev/null +++ b/web-admin/src/utils/protocol-payment-labels.ts @@ -0,0 +1,51 @@ +export function protocolStatusLabel(s: string) { + const map: Record = { + dsx: '待生效', + sxz: '生效中', + ygq: '已过期', + yzf: '已作废', + zky: '转科研' + } + return map[s] || s || '-' +} + +export function payModeLabel(s: string) { + if (s === 'dj') return '趸交' + if (s === 'fq') return '分期' + return s || '-' +} + +export function payStatusLabel(s: string) { + const map: Record = { + dfj: '待缴费', + jfqrz: '缴费确认中', + yjf: '已缴费', + tfqrz: '退费确认中', + ytf: '已退费' + } + return map[s] || '未知' +} + +export function transferTypeLabel(t: string) { + const map: Record = { + wxzz: '微信转账', + zzbzz: '支付宝转账', + yhkzz: '银行卡转账', + xj: '现金' + } + return map[t] || t || '-' +} + +export function formatPaymentDueDate(d: unknown) { + const s = String(d || '') + if (!s) return '-' + if (s.length === 8) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` + return s +} + +export function formatPaymentAmount(v: unknown) { + if (v == null || v === '') return '-' + const n = Number(v) + if (Number.isNaN(n)) return String(v) + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} diff --git a/web-admin/src/views/cell-storage/installment-warning/index.vue b/web-admin/src/views/cell-storage/installment-warning/index.vue deleted file mode 100644 index cb860a17f..000000000 --- a/web-admin/src/views/cell-storage/installment-warning/index.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - - - diff --git a/web-admin/src/views/cell-storage/payment-confirm/index.vue b/web-admin/src/views/cell-storage/payment-confirm/index.vue index 0834a01d7..d606d599f 100644 --- a/web-admin/src/views/cell-storage/payment-confirm/index.vue +++ b/web-admin/src/views/cell-storage/payment-confirm/index.vue @@ -1,31 +1,132 @@ - + + + diff --git a/web-admin/src/views/cell-storage/payment-info/index.vue b/web-admin/src/views/cell-storage/payment-info/index.vue new file mode 100644 index 000000000..2e54879e7 --- /dev/null +++ b/web-admin/src/views/cell-storage/payment-info/index.vue @@ -0,0 +1,888 @@ + + + + + + + diff --git a/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue b/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue index 02b0a9fb0..c98b64742 100644 --- a/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue +++ b/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue @@ -908,7 +908,7 @@ - + String(n).padStart(2, '0') const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` const isFirst = formData.paymentDetails.length === 0 + // 趸交模式下应缴费时间默认等于趸交截止时间 + const defaultDueDate = formData.payMode === 'dj' ? formData.lumpSumDeadline : '' formData.paymentDetails.push({ transferType: '', dueAmount: null, transferAmount: null, transferTime: defaultTime, payStatus: 'dfj', - installmentDueDate: '', + installmentDueDate: defaultDueDate, _expanded: isFirst, attachments: [] as Array<{ id?: string @@ -1754,10 +1756,21 @@ if (val === 'fq') { tryGenerateInstallmentDetails() } else { + // 趸交模式:将趸交截止时间同步到所有明细的应缴费时间 syncPaymentDueAmounts() + syncLumpSumDueDate() } } + /** 趸交模式:将趸交截止时间同步到所有缴费明细的应缴费时间 */ + function syncLumpSumDueDate() { + if (formData.payMode !== 'dj') return + const deadline = formData.lumpSumDeadline || '' + formData.paymentDetails.forEach((d) => { + d.installmentDueDate = deadline + }) + } + function handleOpenForAdd() { isEdit.value = false dialogTitle.value = '新增协议单' @@ -2621,6 +2634,16 @@ () => calcBirthFromIdCard() ) + /* ====== 趸交截止时间联动 ====== */ + watch( + () => formData.lumpSumDeadline, + () => { + if (formData.payMode === 'dj') { + syncLumpSumDueDate() + } + } + ) + /* ====== 附件上传 ====== */ const attachmentUploadRef = ref() diff --git a/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue b/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue index 0d088fc60..0f0f2bbcd 100644 --- a/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue +++ b/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue @@ -313,6 +313,56 @@ /> + + @@ -494,6 +544,8 @@ const collectorDisplay = computed(() => { const fromList = props.listRow?.collectorUserName if (fromList) return String(fromList) + const fromBundle = protocol.value?.collectorUserName + if (fromBundle) return String(fromBundle) const id = protocol.value?.collectorUserId const u = collectorUserOptions.value.find((x) => x.id === id) return u?.nickName || dash(id) @@ -502,6 +554,8 @@ const salesDisplay = computed(() => { const fromList = props.listRow?.salesUserName if (fromList) return String(fromList) + const fromBundle = collectionInfo.value?.salesUserName + if (fromBundle) return String(fromBundle) const id = collectionInfo.value?.salesUserId const u = salesUserOptions.value.find((x) => x.id === id) return u?.nickName || dash(id) @@ -510,6 +564,8 @@ const regionDisplay = computed(() => { const fromList = props.listRow?.regionName if (fromList) return String(fromList) + const fromBundle = collectionInfo.value?.regionName + if (fromBundle) return String(fromBundle) const id = collectionInfo.value?.regionId const r = regionOptions.value.find((x) => x.id === id) return r?.regionName || dash(id) @@ -518,6 +574,8 @@ const hospitalDisplay = computed(() => { const fromList = props.listRow?.hospitalName if (fromList) return String(fromList) + const fromBundle = collectionInfo.value?.hospitalName + if (fromBundle) return String(fromBundle) const id = collectionInfo.value?.hospitalId const h = hospitalOptions.value.find((x) => x.id === id) return h?.hospitalName || dash(id) @@ -649,6 +707,131 @@ return Array.isArray(detail.attachments) ? detail.attachments : [] } + function financeConfirmAttachmentFiles(detail: Record) { + if (Array.isArray(detail.financeConfirmAttachmentFiles)) { + return detail.financeConfirmAttachmentFiles + } + return urlsToAttachmentFiles(String(detail.financeConfirmAttachments || '')) + } + + function refundAttachmentFiles(detail: Record) { + if (Array.isArray(detail.refundAttachmentFiles)) { + return detail.refundAttachmentFiles + } + return urlsToAttachmentFiles(String(detail.refundAttachments || '')) + } + + function refundFinanceConfirmAttachmentFiles(detail: Record) { + if (Array.isArray(detail.refundFinanceConfirmAttachmentFiles)) { + return detail.refundFinanceConfirmAttachmentFiles + } + return urlsToAttachmentFiles(String(detail.refundFinanceConfirmAttachments || '')) + } + + function urlsToAttachmentFiles(urls: string) { + if (!urls.trim()) return [] + return urls.split(',').map((u, i) => { + const url = u.trim() + if (!url) return null + const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url + return { id: `url_${i}`, fileUrl: url, fileName: name, fileSize: 0 } + }).filter(Boolean) as Array> + } + + function hasFinanceRemark(detail: Record) { + return String(detail.financeRemark || '').trim() !== '' + } + + function hasFinanceConfirmAttachments(detail: Record) { + return financeConfirmAttachmentFiles(detail).length > 0 + } + + function hasRefundApplyInfo(detail: Record) { + const amount = detail.refundAmount + const hasAmount = amount != null && amount !== '' && Number(amount) > 0 + return ( + hasAmount || + String(detail.refundRemark || '').trim() !== '' || + refundAttachmentFiles(detail).length > 0 + ) + } + + /** 缴费财务确认:已缴费或退费确认中,且已有财务确认字段 */ + function showPaymentFinanceConfirm(detail: Record) { + const status = String(detail.payStatus || '') + if (status === 'ytf') return false + if (status !== 'yjf' && status !== 'tfqrz') return false + return hasFinanceRemark(detail) || hasFinanceConfirmAttachments(detail) + } + + /** 退费确认申请:发起退费时填写 */ + function showRefundApply(detail: Record) { + const status = String(detail.payStatus || '') + if (status !== 'tfqrz' && status !== 'ytf') return false + return hasRefundApplyInfo(detail) + } + + function hasRefundFinanceRemark(detail: Record) { + return String(detail.refundFinanceRemark || '').trim() !== '' + } + + function hasRefundFinanceConfirmAttachments(detail: Record) { + return refundFinanceConfirmAttachmentFiles(detail).length > 0 + } + + /** 财务退费确认:已退费时展示的财务确认信息 */ + function showRefundFinanceConfirm(detail: Record) { + if (String(detail.payStatus || '') !== 'ytf') return false + return hasRefundFinanceRemark(detail) || hasRefundFinanceConfirmAttachments(detail) + } + + interface PaymentFlowNode { + key: string + title: string + remarkLabel: string + attachmentLabel: string + remark?: unknown + amount?: unknown + attachments: Array> + } + + /** 缴费明细 — 财务/退费流程横向节点(按业务顺序) */ + function buildPaymentFlowNodes(detail: Record): PaymentFlowNode[] { + const nodes: PaymentFlowNode[] = [] + if (showPaymentFinanceConfirm(detail)) { + nodes.push({ + key: 'receipt-finance', + title: '缴费财务确认', + remarkLabel: '财务备注', + attachmentLabel: '财务附件', + remark: detail.financeRemark, + attachments: financeConfirmAttachmentFiles(detail) + }) + } + if (showRefundApply(detail)) { + nodes.push({ + key: 'refund-apply', + title: '退费确认申请', + remarkLabel: '退费备注', + attachmentLabel: '退费附件', + remark: detail.refundRemark, + amount: detail.refundAmount, + attachments: refundAttachmentFiles(detail) + }) + } + if (showRefundFinanceConfirm(detail)) { + nodes.push({ + key: 'refund-finance', + title: '财务退费确认', + remarkLabel: '财务备注', + attachmentLabel: '财务附件', + remark: detail.refundFinanceRemark, + attachments: refundFinanceConfirmAttachmentFiles(detail) + }) + } + return nodes + } + function fullFileUrl(suffix: string) { if (!suffix) return '' if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) @@ -947,11 +1130,133 @@ } .payment-detail-card__attachments { - margin-top: 8px; - padding-top: 8px; + margin-top: 4px; + padding-top: 12px; border-top: 1px dashed #e2e8f0; } + .payment-flow { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid #e2e8f0; + } + + .payment-flow-scroll { + overflow-x: auto; + padding: 0 0 6px 4px; + + &::-webkit-scrollbar { + height: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #d1d5db; + border-radius: 2px; + } + } + + .payment-flow-track { + display: inline-flex; + min-width: 100%; + align-items: flex-start; + } + + .payment-flow-item { + display: flex; + flex-direction: column; + min-width: 200px; + flex: 1 0 200px; + max-width: 280px; + } + + .payment-flow-rail { + display: flex; + align-items: center; + height: 22px; + } + + .payment-flow-node { + position: relative; + z-index: 1; + flex-shrink: 0; + width: 12px; + height: 12px; + border: 2px solid #fff; + border-radius: 50%; + background: var(--primary, #10b981); + box-shadow: 0 0 0 1px var(--primary, #10b981); + } + + .payment-flow-line { + flex: 1; + height: 2px; + margin-left: 0; + background: #d1d5db; + } + + .payment-flow-body { + padding: 8px 12px 0 0; + font-size: 13px; + line-height: 1.45; + } + + .payment-flow-title { + margin-bottom: 8px; + font-size: 13px; + font-weight: 600; + color: #334155; + } + + .payment-flow-kv { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 8px; + } + + .payment-flow-kv--remark .payment-flow-kv__value { + word-break: break-word; + } + + .payment-flow-kv__label { + font-size: 11px; + font-weight: 500; + color: #94a3b8; + } + + .payment-flow-kv__value { + color: #1e293b; + } + + .payment-flow-attachments { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + + :deep(.attachment-list) { + flex-wrap: nowrap; + gap: 8px; + } + + :deep(.attachment-item) { + width: 88px; + flex-shrink: 0; + } + + :deep(.attachment-preview) { + height: 64px; + } + + :deep(.attachment-footer) { + padding: 4px 6px; + } + + :deep(.attachment-name) { + font-size: 11px; + } + } + .view-field-block { display: flex; flex-direction: column; diff --git a/web-admin/src/views/cell-storage/protocol/index.vue b/web-admin/src/views/cell-storage/protocol/index.vue index dd46aa549..77f0db59d 100644 --- a/web-admin/src/views/cell-storage/protocol/index.vue +++ b/web-admin/src/views/cell-storage/protocol/index.vue @@ -248,7 +248,12 @@ /> - + diff --git a/web-admin/src/views/cell-storage/refund-confirm/index.vue b/web-admin/src/views/cell-storage/refund-confirm/index.vue index 96af073de..2e43e9394 100644 --- a/web-admin/src/views/cell-storage/refund-confirm/index.vue +++ b/web-admin/src/views/cell-storage/refund-confirm/index.vue @@ -4,27 +4,142 @@

退费确认

-

仅处理「退费确认中」明细,确认后变为已退费。

+

+ 待确认:处理「退费确认中」明细;已确认:查看财务确认退费记录及当时填写的备注与附件。 +

+
+
+ + + {{ tab.label }} + + +
+
+

退费确认列表

-
+
- + - - - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -44,15 +159,28 @@ + +
+

财务确认

- - - + + + + +
+

财务确认信息

+ +
+ + {{ viewRow?.updateTime || '-' }} + + + {{ + viewRow?.refundFinanceRemark || '—' + }} + + + + +
+
+ +
+ +