settlement.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. """结算与退款CRUD"""
  2. from typing import Optional, List, Tuple
  3. from datetime import datetime
  4. from decimal import Decimal
  5. from sqlalchemy.orm import Session
  6. from sqlalchemy import and_, func
  7. from app.models.models import BizSettlement, BizSettlementHis, BizRefund, BizRefundHis, BizOrder
  8. from app.crud.base import CRUDBase
  9. from app.crud.order import order_crud
  10. from app.core.utils import generate_id
  11. from app.core.order_status_compat import derive_legacy_status_from_three
  12. class SettlementCRUD(CRUDBase[BizSettlement]):
  13. """结算记录CRUD操作"""
  14. def get_multi_with_filters(
  15. self,
  16. db: Session,
  17. region_id: str = None,
  18. client_id: str = None,
  19. status: str = None,
  20. start_date: str = None,
  21. end_date: str = None,
  22. skip: int = 0,
  23. limit: int = 10
  24. ) -> Tuple[List[BizSettlement], int]:
  25. """多条件查询结算记录"""
  26. query = db.query(BizSettlement).filter(BizSettlement.is_del == 0)
  27. if region_id:
  28. query = query.filter(BizSettlement.region_id == region_id)
  29. if client_id:
  30. query = query.filter(BizSettlement.client_id == client_id)
  31. if status:
  32. query = query.filter(BizSettlement.status == status)
  33. if start_date:
  34. query = query.filter(BizSettlement.create_at >= start_date)
  35. if end_date:
  36. query = query.filter(BizSettlement.create_at <= end_date)
  37. total = query.count()
  38. settlements = query.order_by(BizSettlement.create_at.desc()).offset(skip).limit(limit).all()
  39. return settlements, total
  40. def get_by_order(self, db: Session, order_id: str) -> List[BizSettlement]:
  41. """根据订单获取结算记录"""
  42. return db.query(BizSettlement).filter(
  43. and_(
  44. BizSettlement.order_id == order_id,
  45. BizSettlement.is_del == 0
  46. )
  47. ).all()
  48. def create_settlement(
  49. self,
  50. db: Session,
  51. order_id: str,
  52. client_id: str,
  53. region_id: str,
  54. amount: Decimal,
  55. settlement_method: str,
  56. settlement_time: datetime = None,
  57. attachment_url: str = None,
  58. note: str = None,
  59. creator: dict = None
  60. ) -> BizSettlement:
  61. """创建结算记录"""
  62. settlement = BizSettlement(
  63. id=generate_id(),
  64. order_id=order_id,
  65. client_id=client_id,
  66. region_id=region_id,
  67. amount=amount,
  68. settlement_method=settlement_method,
  69. settlement_time=settlement_time or datetime.now(),
  70. settlement_file_url=attachment_url,
  71. settlement_note=note,
  72. status="confirmed",
  73. reviewed_by=creator.get("user_id") if creator else None,
  74. reviewed_time=datetime.now(),
  75. create_by=creator.get("user_id") if creator else None,
  76. create_name=creator.get("real_name") if creator else None
  77. )
  78. db.add(settlement)
  79. db.flush()
  80. # 更新订单的已付金额
  81. order = db.query(BizOrder).filter(BizOrder.id == order_id).first()
  82. if order:
  83. order.paid_amount = (order.paid_amount or Decimal("0")) + amount
  84. if order.paid_amount >= order.receivable_amount:
  85. order.paid_amount = order.receivable_amount
  86. order.update_at = datetime.now()
  87. order_crud.sync_pay_fields_from_amounts(order)
  88. db.commit()
  89. db.refresh(settlement)
  90. return settlement
  91. def batch_create_settlements(
  92. self,
  93. db: Session,
  94. order_ids: List[str],
  95. settlement_method: str,
  96. settlement_time: datetime = None,
  97. attachment_url: str = None,
  98. note: str = None,
  99. creator: dict = None
  100. ) -> List[BizSettlement]:
  101. """批量创建结算记录"""
  102. settlements = []
  103. for order_id in order_ids:
  104. order = db.query(BizOrder).filter(
  105. and_(
  106. BizOrder.id == order_id,
  107. BizOrder.is_del == 0
  108. )
  109. ).first()
  110. if order and order.status not in ["cancelled", "completed"]:
  111. # 计算未付金额
  112. unpaid_amount = (order.receivable_amount or Decimal("0")) - (order.paid_amount or Decimal("0"))
  113. settlement = BizSettlement(
  114. id=generate_id(),
  115. order_id=order_id,
  116. client_id=order.client_id,
  117. region_id=order.region_id,
  118. amount=unpaid_amount,
  119. settlement_method=settlement_method,
  120. settlement_time=settlement_time or datetime.now(),
  121. settlement_file_url=attachment_url,
  122. settlement_note=note,
  123. status="confirmed",
  124. reviewed_by=creator.get("user_id") if creator else None,
  125. reviewed_time=datetime.now(),
  126. create_by=creator.get("user_id") if creator else None,
  127. create_name=creator.get("real_name") if creator else None
  128. )
  129. db.add(settlement)
  130. settlements.append(settlement)
  131. # 更新订单的已付金额
  132. order.paid_amount = order.receivable_amount
  133. order.update_at = datetime.now()
  134. order_crud.sync_pay_fields_from_amounts(order)
  135. db.commit()
  136. return settlements
  137. def confirm_settlement(
  138. self,
  139. db: Session,
  140. settlement_id: str,
  141. reviewer_id: str,
  142. note: str = None
  143. ) -> Optional[BizSettlement]:
  144. """确认结算"""
  145. settlement = self.get(db, settlement_id)
  146. if not settlement:
  147. return None
  148. if settlement.status != "pending":
  149. return None
  150. settlement.status = "confirmed"
  151. settlement.reviewed_by = reviewer_id
  152. settlement.reviewed_time = datetime.now()
  153. if note:
  154. settlement.settlement_note = note
  155. settlement.update_at = datetime.now()
  156. # 更新订单的已付金额
  157. order = db.query(BizOrder).filter(BizOrder.id == settlement.order_id).first()
  158. if order:
  159. order.paid_amount = (order.paid_amount or Decimal("0")) + settlement.amount
  160. if order.paid_amount >= order.receivable_amount:
  161. order.paid_amount = order.receivable_amount
  162. order.update_at = datetime.now()
  163. order_crud.sync_pay_fields_from_amounts(order)
  164. db.commit()
  165. db.refresh(settlement)
  166. return settlement
  167. def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizSettlement]:
  168. """更新结算记录并记录历史"""
  169. original = self.get(db, id)
  170. if not original:
  171. return None
  172. his_data = {
  173. "id": original.id,
  174. "order_id": original.order_id,
  175. "client_id": original.client_id,
  176. "region_id": original.region_id,
  177. "amount": original.amount,
  178. "settlement_method": original.settlement_method,
  179. "settlement_time": original.settlement_time,
  180. "settlement_file_url": original.settlement_file_url,
  181. "settlement_note": original.settlement_note,
  182. "status": original.status,
  183. "reviewed_by": original.reviewed_by,
  184. "reviewed_time": original.reviewed_time,
  185. "create_at": original.create_at,
  186. "create_by": original.create_by,
  187. "create_name": original.create_name,
  188. "update_at": original.update_at,
  189. "update_by": original.update_by,
  190. }
  191. his = BizSettlementHis(**his_data)
  192. db.add(his)
  193. return self.update(db, id, obj_in, updater)
  194. def get_client_summary(
  195. self,
  196. db: Session,
  197. region_id: str = None,
  198. client_id: str = None,
  199. skip: int = 0,
  200. limit: int = 10
  201. ) -> Tuple[List[dict], int]:
  202. """获取客户待结算汇总"""
  203. query = db.query(
  204. BizOrder.client_id,
  205. func.count(BizOrder.id).label("order_count"),
  206. func.sum(BizOrder.receivable_amount - BizOrder.paid_amount).label("total_unpaid")
  207. ).filter(
  208. and_(
  209. BizOrder.is_del == 0,
  210. BizOrder.receivable_amount > BizOrder.paid_amount,
  211. BizOrder.status.notin_(["cancelled"])
  212. )
  213. )
  214. if region_id:
  215. query = query.filter(BizOrder.region_id == region_id)
  216. if client_id:
  217. query = query.filter(BizOrder.client_id == client_id)
  218. query = query.group_by(BizOrder.client_id)
  219. total = query.count()
  220. results = query.offset(skip).limit(limit).all()
  221. return [{"client_id": r.client_id, "order_count": r.order_count, "total_unpaid": r.total_unpaid or Decimal("0")} for r in results], total
  222. def get_statistics(
  223. self,
  224. db: Session,
  225. region_id: str = None,
  226. start_date: str = None,
  227. end_date: str = None
  228. ) -> dict:
  229. """获取结算统计数据"""
  230. # 待结算金额(使用单条SQL查询,避免N+1问题)
  231. pending_query = db.query(
  232. func.count(BizOrder.id).label("order_count"),
  233. func.coalesce(func.sum(BizOrder.receivable_amount - BizOrder.paid_amount), 0).label("pending_amount")
  234. ).filter(
  235. and_(
  236. BizOrder.is_del == 0,
  237. BizOrder.receivable_amount > BizOrder.paid_amount,
  238. BizOrder.status.notin_(["cancelled", "completed"])
  239. )
  240. )
  241. if region_id:
  242. pending_query = pending_query.filter(BizOrder.region_id == region_id)
  243. pending_result = pending_query.first()
  244. # 已结算金额
  245. settled_query = db.query(
  246. func.count(BizSettlement.id).label("settlement_count"),
  247. func.coalesce(func.sum(BizSettlement.amount), 0).label("settled_amount")
  248. ).filter(
  249. and_(
  250. BizSettlement.is_del == 0,
  251. BizSettlement.status == "confirmed"
  252. )
  253. )
  254. if region_id:
  255. settled_query = settled_query.filter(BizSettlement.region_id == region_id)
  256. if start_date:
  257. settled_query = settled_query.filter(BizSettlement.reviewed_time >= start_date)
  258. if end_date:
  259. settled_query = settled_query.filter(BizSettlement.reviewed_time <= end_date)
  260. settled_result = settled_query.first()
  261. # 待处理退款数
  262. refund_pending_count = db.query(func.count(BizRefund.id)).filter(
  263. and_(
  264. BizRefund.is_del == 0,
  265. BizRefund.status.in_(["pending", "processing"])
  266. )
  267. )
  268. if region_id:
  269. refund_pending_count = refund_pending_count.join(
  270. BizOrder, BizOrder.id == BizRefund.order_id
  271. ).filter(BizOrder.region_id == region_id)
  272. refund_pending_count = refund_pending_count.scalar() or 0
  273. return {
  274. "pendingOrderCount": pending_result.order_count or 0,
  275. "pendingAmount": float(pending_result.pending_amount or 0),
  276. "settledCount": settled_result.settlement_count or 0,
  277. "settledAmount": float(settled_result.settled_amount or 0),
  278. "refundPendingCount": refund_pending_count
  279. }
  280. class RefundCRUD(CRUDBase[BizRefund]):
  281. """退款记录CRUD操作"""
  282. def get_multi_with_filters(
  283. self,
  284. db: Session,
  285. region_id: str = None,
  286. order_id: str = None,
  287. order_no: str = None,
  288. status: str = None,
  289. refund_method: str = None,
  290. start_date: str = None,
  291. end_date: str = None,
  292. skip: int = 0,
  293. limit: int = 10
  294. ) -> Tuple[List[BizRefund], int]:
  295. """多条件查询退款记录"""
  296. query = db.query(BizRefund).filter(BizRefund.is_del == 0)
  297. if region_id:
  298. # 需要关联订单获取区域
  299. query = query.join(BizOrder, BizOrder.id == BizRefund.order_id).filter(
  300. BizOrder.region_id == region_id
  301. )
  302. if order_id:
  303. query = query.filter(BizRefund.order_id == order_id)
  304. if order_no:
  305. query = query.join(BizOrder, BizOrder.id == BizRefund.order_id).filter(
  306. BizOrder.order_no.like(f"%{order_no}%")
  307. )
  308. if status:
  309. query = query.filter(BizRefund.status == status)
  310. if refund_method:
  311. query = query.filter(BizRefund.refund_method == refund_method)
  312. if start_date:
  313. query = query.filter(BizRefund.create_at >= start_date)
  314. if end_date:
  315. query = query.filter(BizRefund.create_at <= end_date)
  316. total = query.count()
  317. refunds = query.order_by(BizRefund.create_at.desc()).offset(skip).limit(limit).all()
  318. return refunds, total
  319. def get_by_order(self, db: Session, order_id: str) -> List[BizRefund]:
  320. """根据订单获取退款记录"""
  321. return db.query(BizRefund).filter(
  322. and_(
  323. BizRefund.order_id == order_id,
  324. BizRefund.is_del == 0
  325. )
  326. ).all()
  327. def get_by_refund_no(self, db: Session, refund_no: str) -> Optional[BizRefund]:
  328. """根据退款单号获取退款记录"""
  329. return db.query(BizRefund).filter(
  330. and_(
  331. BizRefund.refund_no == refund_no,
  332. BizRefund.is_del == 0
  333. )
  334. ).first()
  335. def create_refund(
  336. self,
  337. db: Session,
  338. order_id: str,
  339. refund_amount: Decimal,
  340. refund_method: str,
  341. reason: str = None,
  342. creator: dict = None
  343. ) -> BizRefund:
  344. """创建退款记录"""
  345. refund_no = self._generate_refund_no(db)
  346. refund = BizRefund(
  347. id=generate_id(),
  348. order_id=order_id,
  349. refund_no=refund_no,
  350. refund_amount=refund_amount,
  351. refund_method=refund_method,
  352. refund_result="processing",
  353. status="pending",
  354. create_by=creator.get("user_id") if creator else None,
  355. create_name=creator.get("real_name") if creator else None
  356. )
  357. db.add(refund)
  358. # 更新订单的退款金额和状态
  359. order = db.query(BizOrder).filter(BizOrder.id == order_id).first()
  360. if order:
  361. order.refund_amount = (order.refund_amount or Decimal("0")) + refund_amount
  362. order.update_at = datetime.now()
  363. # 如果是微信退款,更新订单状态为退款中
  364. if refund_method == "wechat":
  365. order.pay_status = "refunding"
  366. order.status = derive_legacy_status_from_three(
  367. order_status=order.order_status,
  368. delivery_status=order.delivery_status,
  369. pay_status=order.pay_status,
  370. )
  371. db.commit()
  372. db.refresh(refund)
  373. return refund
  374. def process_refund(
  375. self,
  376. db: Session,
  377. refund_id: str,
  378. refund_result: str,
  379. refund_no_wechat: str = None,
  380. updater: dict = None
  381. ) -> Optional[BizRefund]:
  382. """处理退款(成功/失败)"""
  383. refund = self.get(db, refund_id)
  384. if not refund:
  385. return None
  386. refund.refund_result = refund_result
  387. refund.refund_time = datetime.now()
  388. if refund_no_wechat:
  389. refund.refund_no_wechat = refund_no_wechat
  390. if refund_result == "success":
  391. refund.status = "completed"
  392. # 更新订单退款金额
  393. order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
  394. if order:
  395. order.refund_amount = (order.refund_amount or Decimal("0")) + refund.refund_amount
  396. order.paid_amount = max(Decimal("0"), (order.paid_amount or Decimal("0")) - refund.refund_amount)
  397. order.pay_status = "refunded" if (order.paid_amount or Decimal("0")) <= Decimal("0") else "paid"
  398. order.status = derive_legacy_status_from_three(
  399. order_status=order.order_status,
  400. delivery_status=order.delivery_status,
  401. pay_status=order.pay_status,
  402. )
  403. order.update_at = datetime.now()
  404. # 如果退款金额等于已支付金额,订单变为已取消
  405. if order.refund_amount >= (order.total_amount - order.paid_amount + order.refund_amount):
  406. # 保留部分退款状态
  407. pass
  408. else:
  409. refund.status = "failed"
  410. # 恢复订单状态
  411. order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
  412. if order and order.pay_status == "refunding":
  413. order.pay_status = "paid" if (order.paid_amount or Decimal("0")) > Decimal("0") else "pending_payment"
  414. order.status = derive_legacy_status_from_three(
  415. order_status=order.order_status,
  416. delivery_status=order.delivery_status,
  417. pay_status=order.pay_status,
  418. )
  419. refund.update_at = datetime.now()
  420. db.commit()
  421. db.refresh(refund)
  422. return refund
  423. def handle_refund_failure(
  424. self,
  425. db: Session,
  426. refund_id: str,
  427. fail_reason: str
  428. ) -> Optional[BizRefund]:
  429. """处理退款失败"""
  430. refund = self.get(db, refund_id)
  431. if not refund:
  432. return None
  433. refund.refund_result = "failed"
  434. refund.refund_fail_reason = fail_reason
  435. refund.refund_time = datetime.now()
  436. refund.status = "failed"
  437. refund.update_at = datetime.now()
  438. # 恢复订单状态
  439. order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
  440. if order and order.pay_status == "refunding":
  441. order.pay_status = "paid" if (order.paid_amount or Decimal("0")) > Decimal("0") else "pending_payment"
  442. order.status = derive_legacy_status_from_three(
  443. order_status=order.order_status,
  444. delivery_status=order.delivery_status,
  445. pay_status=order.pay_status,
  446. )
  447. order.update_at = datetime.now()
  448. db.commit()
  449. db.refresh(refund)
  450. return refund
  451. def _generate_refund_no(self, db: Session) -> str:
  452. """生成退款单号"""
  453. today = datetime.now().strftime("%Y%m%d")
  454. prefix = f"REF{today}"
  455. last_refund = db.query(BizRefund).filter(
  456. BizRefund.refund_no.like(f"{prefix}%")
  457. ).order_by(BizRefund.refund_no.desc()).first()
  458. if last_refund:
  459. last_no = int(last_refund.refund_no[-4:])
  460. new_no = last_no + 1
  461. else:
  462. new_no = 1
  463. return f"{prefix}{str(new_no).zfill(4)}"
  464. def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizRefund]:
  465. """更新退款记录并记录历史"""
  466. original = self.get(db, id)
  467. if not original:
  468. return None
  469. his_data = {
  470. "id": original.id,
  471. "order_id": original.order_id,
  472. "refund_no": original.refund_no,
  473. "refund_amount": original.refund_amount,
  474. "refund_method": original.refund_method,
  475. "refund_no_wechat": original.refund_no_wechat,
  476. "refund_time": original.refund_time,
  477. "refund_result": original.refund_result,
  478. "refund_fail_reason": original.refund_fail_reason,
  479. "status": original.status,
  480. "create_at": original.create_at,
  481. "create_by": original.create_by,
  482. "create_name": original.create_name,
  483. "update_at": original.update_at,
  484. "update_by": original.update_by,
  485. }
  486. his = BizRefundHis(**his_data)
  487. db.add(his)
  488. return self.update(db, id, obj_in, updater)
  489. def get_refund_statistics(
  490. self,
  491. db: Session,
  492. start_date: str = None,
  493. end_date: str = None
  494. ) -> dict:
  495. """获取退款统计数据"""
  496. base_query = db.query(BizRefund).filter(
  497. and_(
  498. BizRefund.is_del == 0,
  499. BizRefund.status == "completed"
  500. )
  501. )
  502. if start_date:
  503. base_query = base_query.filter(BizRefund.refund_time >= start_date)
  504. if end_date:
  505. base_query = base_query.filter(BizRefund.refund_time <= end_date)
  506. # 成功退款金额
  507. success_amount = base_query.filter(
  508. BizRefund.refund_result == "success"
  509. ).func.sum(BizRefund.refund_amount).scalar() or Decimal("0")
  510. # 失败退款金额
  511. failed_amount = base_query.filter(
  512. BizRefund.refund_result == "failed"
  513. ).func.sum(BizRefund.refund_amount).scalar() or Decimal("0")
  514. # 待处理退款数
  515. pending_count = db.query(func.count(BizRefund.id)).filter(
  516. and_(
  517. BizRefund.is_del == 0,
  518. BizRefund.status.in_(["pending", "processing"])
  519. )
  520. ).scalar() or 0
  521. # 退款失败数
  522. failed_count = db.query(func.count(BizRefund.id)).filter(
  523. and_(
  524. BizRefund.is_del == 0,
  525. BizRefund.status == "failed"
  526. )
  527. ).scalar() or 0
  528. return {
  529. "successAmount": float(success_amount),
  530. "failedAmount": float(failed_amount),
  531. "pendingCount": pending_count,
  532. "failedCount": failed_count
  533. }
  534. settlement_crud = SettlementCRUD(BizSettlement)
  535. refund_crud = RefundCRUD(BizRefund)