diff --git a/backend/app/api/presign_order.py b/backend/app/api/presign_order.py index bb2ce1aa..d4ed5a1a 100644 --- a/backend/app/api/presign_order.py +++ b/backend/app/api/presign_order.py @@ -46,6 +46,7 @@ class PresignOrderBody(BaseModel): sample_collect_time: Optional[str] = Field(None, alias="sampleCollectTime") pay_mode: Optional[str] = Field(None, alias="payMode") contract_amount: Optional[float] = Field(None, alias="contractAmount") + collector_user_id: Optional[str] = Field(None, alias="collectorUserId") sales_user_id: Optional[str] = Field(None, alias="salesUserId") region_manager_user_id: Optional[str] = Field(None, alias="regionManagerUserId") order_status: Optional[str] = Field(None, alias="orderStatus") @@ -286,6 +287,27 @@ async def submit_presign_transfer_finance( return error_response(400, str(e)) +@router.post("/presign-transfers/{transfer_id}/withdraw") +async def withdraw_presign_transfer( + transfer_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + user_id = _get_current_user_id(user) + obj = po_svc.withdraw_receipt( + db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id + ) + return success_response( + po_svc._transfer_to_dict(db, obj, tenant_no), message="已撤回" + ) + except PermissionError as e: + return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) + + @router.get("/presign-transfers/receipt-confirm") async def list_presign_receipt_confirm( page: int = Query(1, ge=1), diff --git a/backend/app/crud/presign_order.py b/backend/app/crud/presign_order.py index a931f75d..bbe77cc3 100644 --- a/backend/app/crud/presign_order.py +++ b/backend/app/crud/presign_order.py @@ -12,13 +12,19 @@ from sqlalchemy.orm import Session from app.core.utils import generate_uuid, parse_datetime from app.crud.collection_protocol import urls_to_attachment_list from app.models.models import ( + BizCellSampleType, BizCollectionProtocol, BizPresignOrder, BizPresignOrderSampleType, BizPresignTransfer, BizProtocolPartyCustomer, + SysUser, +) +from app.services.protocol_transfer_status import ( + can_confirm_receipt, + can_submit_receipt, + can_withdraw_receipt, ) -from app.services.protocol_transfer_status import can_confirm_receipt, can_submit_receipt def _now() -> datetime: @@ -31,6 +37,17 @@ def _dec(v: Any) -> Optional[Decimal]: return Decimal(str(v)) +def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]: + ids = [str(x).strip() for x in user_ids if str(x or "").strip()] + if not ids: + return {} + rows = db.query(SysUser.id, SysUser.nick_name, SysUser.user_no).filter(SysUser.id.in_(ids)).all() + out: Dict[str, str] = {} + for u in rows: + out[str(u.id)] = (u.nick_name or u.user_no or "").strip() + return out + + def assert_presign_convertible(*, order_status: Optional[str]) -> None: st = (order_status or "").strip() or "draft" if st == "converted": @@ -39,6 +56,24 @@ def assert_presign_convertible(*, order_status: Optional[str]) -> None: raise ValueError("已作废的预签约订单不能转正式") +def resolve_convert_staff_fields( + *, + collector_user_id: Optional[str], + sales_user_id: Optional[str], + region_manager_user_id: Optional[str], + fallback_user_id: Optional[str] = None, +) -> Dict[str, Optional[str]]: + """转正式人员映射:采集专员→采集专员+市场专员;采集经理→地区经理。""" + _ = sales_user_id # 正式单市场专员以采集专员为准,不再沿用预签市场专员 + collector = (collector_user_id or "").strip() or (fallback_user_id or "").strip() or None + region_mgr = (region_manager_user_id or "").strip() or None + return { + "collector_user_id": collector, + "sales_user_id": collector, + "region_manager_user_id": region_mgr, + } + + def _get_order_or_raise( db: Session, *, order_id: str, tenant_no: str ) -> BizPresignOrder: @@ -117,6 +152,27 @@ def confirmed_deposit_amount( return total +def _sample_type_names( + db: Session, *, presign_order_id: str, tenant_no: str +) -> str: + ids = list_sample_type_ids( + db, presign_order_id=presign_order_id, tenant_no=tenant_no + ) + if not ids: + return "" + rows = ( + db.query(BizCellSampleType.id, BizCellSampleType.type_name) + .filter( + BizCellSampleType.id.in_(ids), + BizCellSampleType.del_flag == 0, + ) + .all() + ) + name_map = {str(r.id): (r.type_name or "").strip() for r in rows} + names = [name_map.get(i, "") for i in ids if name_map.get(i)] + return "、".join(names) + + def list_sample_type_ids( db: Session, *, presign_order_id: str, tenant_no: str ) -> List[str]: @@ -218,6 +274,7 @@ def order_to_dict( else "", "payMode": o.pay_mode or "", "contractAmount": float(o.contract_amount) if o.contract_amount is not None else None, + "collectorUserId": o.collector_user_id or "", "salesUserId": o.sales_user_id or "", "regionManagerUserId": o.region_manager_user_id or "", "orderStatus": o.order_status or "", @@ -227,6 +284,17 @@ def order_to_dict( "createTime": o.create_time.strftime("%Y-%m-%d %H:%M:%S") if o.create_time else "", "updateTime": o.update_time.strftime("%Y-%m-%d %H:%M:%S") if o.update_time else "", } + name_map = _user_nick_name_map( + db, + [ + data["collectorUserId"], + data["salesUserId"], + data["regionManagerUserId"], + ], + ) + data["collectorUserName"] = name_map.get(data["collectorUserId"], "") + data["salesUserName"] = name_map.get(data["salesUserId"], "") + data["regionManagerUserName"] = name_map.get(data["regionManagerUserId"], "") if with_children: data["sampleTypeIds"] = list_sample_type_ids( db, presign_order_id=o.id, tenant_no=tenant_no @@ -339,6 +407,11 @@ def create_presign_order( sample_collect_time=sct, pay_mode=pay_mode, contract_amount=_dec(fields.get("contract_amount", fields.get("contractAmount"))), + collector_user_id=( + fields.get("collector_user_id") or fields.get("collectorUserId") or "" + ).strip() + or user_id + or None, sales_user_id=(fields.get("sales_user_id") or fields.get("salesUserId") or "").strip() or None, region_manager_user_id=( @@ -411,6 +484,8 @@ def update_presign_order( "hospitalId": "hospital_id", "pay_mode": "pay_mode", "payMode": "pay_mode", + "collector_user_id": "collector_user_id", + "collectorUserId": "collector_user_id", "sales_user_id": "sales_user_id", "salesUserId": "sales_user_id", "region_manager_user_id": "region_manager_user_id", @@ -430,6 +505,7 @@ def update_presign_order( "region_id", "hospital_id", "pay_mode", + "collector_user_id", "sales_user_id", "region_manager_user_id", "order_status", @@ -540,7 +616,7 @@ def update_transfer_draft( ) -> BizPresignTransfer: t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no) if t.pay_status != "dfj": - raise ValueError("仅待缴费状态可编辑意向金转账笔") + raise ValueError("仅已缴费状态可编辑意向金转账笔") if "transfer_amount" in fields or "transferAmount" in fields: t.transfer_amount = _dec( fields.get("transfer_amount", fields.get("transferAmount")) @@ -579,7 +655,7 @@ def submit_receipt_finance( ) -> BizPresignTransfer: t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no) if not can_submit_receipt(t.pay_status): - raise ValueError("仅待缴费状态可提交财务确认") + raise ValueError("仅已缴费状态可提交财务确认") if not (t.transfer_type or "").strip(): raise ValueError("请选择转账方式") if t.transfer_amount is None or Decimal(str(t.transfer_amount)) <= 0: @@ -593,6 +669,22 @@ def submit_receipt_finance( return t +def withdraw_receipt( + db: Session, *, transfer_id: str, tenant_no: str, user_id: Optional[str] +) -> BizPresignTransfer: + t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no) + if not can_withdraw_receipt(t.pay_status): + raise ValueError("仅缴费确认中可撤回") + t.pay_status = "dfj" + # 故意不改 finance_remark / finance_confirm_attachments + t.update_by = user_id + t.update_time = _now() + db.add(t) + db.commit() + db.refresh(t) + return t + + def confirm_receipt( db: Session, *, @@ -690,6 +782,42 @@ def list_presign_transfers_for_finance( item["motherName"] = (order.mother_name if order else "") or "" item["sampleCode"] = "" item["presignOrderId"] = order.id if order else "" + item["orderDate"] = (order.order_date if order else "") or "" + item["expectedDeliveryDate"] = ( + (order.expected_delivery_date if order else "") or "" + ) + item["contractAmount"] = ( + float(order.contract_amount) + if order and order.contract_amount is not None + else None + ) + item["contractPayMode"] = (order.pay_mode if order else "") or "" + item["orderStatus"] = (order.order_status if order else "") or "" + item["orderRemark"] = (order.remark if order else "") or "" + name_map = _user_nick_name_map( + db, + [ + (order.collector_user_id if order else "") or "", + (order.sales_user_id if order else "") or "", + (order.region_manager_user_id if order else "") or "", + ], + ) + item["collectorUserName"] = name_map.get( + (order.collector_user_id if order else "") or "", "" + ) + item["salesUserName"] = name_map.get( + (order.sales_user_id if order else "") or "", "" + ) + item["regionManagerUserName"] = name_map.get( + (order.region_manager_user_id if order else "") or "", "" + ) + item["sampleTypeNames"] = ( + _sample_type_names( + db, presign_order_id=order.id, tenant_no=tenant_no + ) + if order + else "" + ) out.append(item) return out, int(total) @@ -701,19 +829,26 @@ def convert_to_protocol( tenant_no: str, user_id: Optional[str], ) -> Dict[str, Any]: - """转正式协议:创建协议主单 + 当事人/采集信息 + 样本类型行;不写入样本缴费金额。""" + """转正式协议:创建协议主单 + 当事人/采集信息 + 样本类型行;意向金仅关联预签订单 ID,不拷贝。""" from app.crud import collection_protocol as cp_svc order = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no) assert_presign_convertible(order_status=order.order_status) + staff = resolve_convert_staff_fields( + collector_user_id=order.collector_user_id, + sales_user_id=order.sales_user_id, + region_manager_user_id=order.region_manager_user_id, + fallback_user_id=user_id, + ) + type_ids = list_sample_type_ids(db, presign_order_id=order.id, tenant_no=tenant_no) protocol = cp_svc.create_collection_protocol( db, protocol_no=order.protocol_no or "", protocol_status="dsx", register_date=order.order_date, - collector_user_id=user_id, + collector_user_id=staff["collector_user_id"], source_presign_order_id=order.id, payment_model_version=2, protocol_total_amount=None, @@ -731,7 +866,8 @@ def convert_to_protocol( collection_fields: Dict[str, Any] = { "region_id": order.region_id, "hospital_id": order.hospital_id, - "sales_user_id": order.sales_user_id, + "sales_user_id": staff["sales_user_id"], + "region_manager_user_id": staff["region_manager_user_id"], "sample_collect_time": order.sample_collect_time, } cp_svc.upsert_collection_info( diff --git a/backend/tests/test_presign_order.py b/backend/tests/test_presign_order.py index 8daf6536..e7a0bf42 100644 --- a/backend/tests/test_presign_order.py +++ b/backend/tests/test_presign_order.py @@ -96,5 +96,52 @@ class TestProtocolDepositAssociation(unittest.TestCase): ) +class TestPresignWithdrawReceipt(unittest.TestCase): + def test_withdraw_sets_dfj_keeps_finance_remark(self): + from types import SimpleNamespace + from unittest.mock import MagicMock + from app.crud import presign_order as po + + t = SimpleNamespace( + id="t1", + pay_status="jfqrz", + finance_remark="keep-me", + finance_confirm_attachments="http://x", + update_by=None, + update_time=None, + ) + db = MagicMock() + orig = po._get_transfer_or_raise + po._get_transfer_or_raise = lambda *a, **k: t + try: + out = po.withdraw_receipt( + db, transfer_id="t1", tenant_no="zkxw", user_id="u1" + ) + self.assertEqual(out.pay_status, "dfj") + self.assertEqual(out.finance_remark, "keep-me") + self.assertEqual(out.finance_confirm_attachments, "http://x") + db.commit.assert_called() + finally: + po._get_transfer_or_raise = orig + + def test_withdraw_rejects_non_jfqrz(self): + from types import SimpleNamespace + from unittest.mock import MagicMock + from app.crud import presign_order as po + + t = SimpleNamespace(id="t1", pay_status="yjf", finance_remark="x") + db = MagicMock() + orig = po._get_transfer_or_raise + po._get_transfer_or_raise = lambda *a, **k: t + try: + with self.assertRaises(ValueError) as ctx: + po.withdraw_receipt( + db, transfer_id="t1", tenant_no="zkxw", user_id="u1" + ) + self.assertIn("撤回", str(ctx.exception)) + finally: + po._get_transfer_or_raise = orig + + if __name__ == "__main__": unittest.main()