service.py 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030
  1. from datetime import datetime
  2. from decimal import Decimal
  3. from app.api.v1.module_system.auth.schema import AuthSchema
  4. from app.core.alipay import AlipayClient
  5. from app.core.exceptions import CustomException
  6. from app.core.logger import log
  7. from app.utils.snowflake import get_snowflake_id
  8. from .enums import QuotaStatusEnum
  9. from .schema import (
  10. AdjustQuotaSchema,
  11. ExpenseQuotaCreateSchema,
  12. ExpenseQuotaDeleteSchema,
  13. ExpenseQuotaModifySchema,
  14. ExpenseQuotaQueryOutSchema,
  15. ExpenseQuotaQuerySchema,
  16. IssueBatchCancelOutSchema,
  17. IssueBatchCancelSchema,
  18. IssueBatchCreateOutSchema,
  19. IssueBatchCreateSchema,
  20. IssueBatchListOutSchema,
  21. IssueBatchRecordsQueryOutSchema,
  22. IssueBatchRecordsQuerySchema,
  23. IssueQuotaCheckFailedItem,
  24. IssueRecordInfoItem,
  25. QuotaCreateSchema,
  26. QuotaDetailInfoSchema,
  27. QuotaListOutSchema,
  28. QuotaOperationOutSchema,
  29. QuotaOutSchema,
  30. QuotaUpdateSchema,
  31. )
  32. from .crud import IssueBatchCRUD, QuotaCRUD
  33. from .model import IssueBatchModel
  34. class QuotaService:
  35. """额度服务层"""
  36. @classmethod
  37. async def create_expense_quota_service(
  38. cls, auth: AuthSchema, data: ExpenseQuotaCreateSchema
  39. ) -> QuotaOperationOutSchema:
  40. """
  41. 创建余额/点券
  42. 调用: alipay.ebpp.invoice.expensecontrol.quota.create
  43. """
  44. crud = QuotaCRUD(auth)
  45. out_biz_no = data.outer_source_id or str(get_snowflake_id())
  46. try:
  47. from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolQuotaCreateRequest import (
  48. AlipayEbppInvoiceExpensecontrolQuotaCreateRequest,
  49. )
  50. from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolQuotaCreateModel import (
  51. AlipayEbppInvoiceExpensecontrolQuotaCreateModel,
  52. )
  53. from alipay.aop.api.domain.IssueQuotaTarget import (
  54. IssueQuotaTarget,
  55. )
  56. from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolQuotaCreateResponse import (
  57. AlipayEbppInvoiceExpensecontrolQuotaCreateResponse,
  58. )
  59. except ImportError:
  60. raise CustomException(msg="支付宝SDK未正确安装,请检查alipay-sdk-python依赖")
  61. model = AlipayEbppInvoiceExpensecontrolQuotaCreateModel()
  62. model.target_type = data.target_type
  63. model.target_id = data.target_id
  64. model.enterprise_id = data.enterprise_id
  65. model.outer_source_id = out_biz_no
  66. model.quota_type = data.quota_type or "CAP"
  67. model.share_mode = data.share_mode or "0"
  68. if data.effective_start_date:
  69. model.effective_start_date = data.effective_start_date.strftime("%Y-%m-%d %H:%M:%S")
  70. if data.effective_end_date:
  71. model.effective_end_date = data.effective_end_date.strftime("%Y-%m-%d %H:%M:%S")
  72. if data.issue_name:
  73. model.issue_name = data.issue_name
  74. if data.issue_desc:
  75. model.issue_desc = data.issue_desc
  76. if data.issue_quota_target_list:
  77. target_list = []
  78. for item in data.issue_quota_target_list:
  79. target = IssueQuotaTarget()
  80. target.owner_type = item.owner_type
  81. target.owner_id = item.owner_id
  82. target.quota = item.quota
  83. if item.amount is not None:
  84. target.amount = item.amount
  85. target_list.append(target)
  86. model.issue_quota_target_list = target_list
  87. request = AlipayEbppInvoiceExpensecontrolQuotaCreateRequest()
  88. request.biz_model = model
  89. client = AlipayClient.get_client()
  90. response = client.execute(request)
  91. if not response:
  92. raise CustomException(msg="创建余额/点券失败: 无响应")
  93. result = AlipayEbppInvoiceExpensecontrolQuotaCreateResponse()
  94. result.parse_response_content(response)
  95. if not result.is_success():
  96. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  97. raise CustomException(msg=f"创建余额/点券失败: {result.msg}")
  98. quota_data = data.model_dump(exclude_none=True)
  99. quota_data["out_biz_no"] = out_biz_no
  100. quota_data["status"] = QuotaStatusEnum.QUOTA_ACTIVE.value
  101. if result.quota_id:
  102. quota_data["quota_id"] = result.quota_id
  103. quota = await crud.create(quota_data)
  104. if not quota:
  105. raise CustomException(msg="创建额度记录失败")
  106. return QuotaOperationOutSchema(out_biz_no=out_biz_no, quota_id=result.quota_id)
  107. @classmethod
  108. async def create_quota_service(
  109. cls, auth: AuthSchema, data: QuotaCreateSchema
  110. ) -> QuotaOperationOutSchema:
  111. """创建额度"""
  112. crud = QuotaCRUD(auth)
  113. out_biz_no = str(get_snowflake_id())
  114. quota_data = data.model_dump(exclude_none=True)
  115. quota_data["out_biz_no"] = out_biz_no
  116. quota_data["status"] = QuotaStatusEnum.QUOTA_ACTIVE.value
  117. if quota_data.get("available_amount") is None:
  118. quota_data["available_amount"] = quota_data.get("total_amount", 0)
  119. quota = await crud.create(quota_data)
  120. if not quota:
  121. raise CustomException(msg="创建额度记录失败")
  122. return QuotaOperationOutSchema(out_biz_no=out_biz_no, quota_id=quota.quota_id)
  123. @classmethod
  124. async def query_expense_quota_service(
  125. cls, auth: AuthSchema, data: ExpenseQuotaQuerySchema
  126. ) -> ExpenseQuotaQueryOutSchema:
  127. """
  128. 查询余额/点券
  129. 调用: alipay.ebpp.invoice.expensecontrol.quota.query
  130. """
  131. try:
  132. from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolQuotaQueryRequest import (
  133. AlipayEbppInvoiceExpensecontrolQuotaQueryRequest,
  134. )
  135. from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolQuotaQueryModel import (
  136. AlipayEbppInvoiceExpensecontrolQuotaQueryModel,
  137. )
  138. from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolQuotaQueryResponse import (
  139. AlipayEbppInvoiceExpensecontrolQuotaQueryResponse,
  140. )
  141. except ImportError:
  142. raise CustomException(msg="支付宝SDK未正确安装,请检查alipay-sdk-python依赖")
  143. model = AlipayEbppInvoiceExpensecontrolQuotaQueryModel()
  144. model.owner_type = data.owner_type
  145. model.page_size = data.page_size
  146. model.page_num = data.page_num
  147. if data.target_type:
  148. model.target_type = data.target_type
  149. if data.target_id:
  150. model.target_id = data.target_id
  151. if data.owner_id:
  152. model.owner_id = data.owner_id
  153. if data.owner_open_id:
  154. model.owner_open_id = data.owner_open_id
  155. if data.enterprise_id:
  156. model.enterprise_id = data.enterprise_id
  157. if data.quota_id_list:
  158. model.quota_id_list = data.quota_id_list
  159. if data.quota_type:
  160. model.quota_type = data.quota_type
  161. request = AlipayEbppInvoiceExpensecontrolQuotaQueryRequest()
  162. request.biz_model = model
  163. client = AlipayClient.get_client()
  164. response = client.execute(request)
  165. if not response:
  166. raise CustomException(msg="查询余额/点券失败: 无响应")
  167. result = AlipayEbppInvoiceExpensecontrolQuotaQueryResponse()
  168. result.parse_response_content(response)
  169. if not result.is_success():
  170. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  171. raise CustomException(msg=f"查询余额/点券失败: {result.msg}")
  172. return ExpenseQuotaQueryOutSchema(
  173. page_num=result.page_num or data.page_num,
  174. page_size=result.page_size or data.page_size,
  175. total_page_count=result.total_page_count or 0,
  176. )
  177. @classmethod
  178. async def modify_expense_quota_service(
  179. cls, auth: AuthSchema, out_biz_no: str, data: ExpenseQuotaModifySchema
  180. ) -> QuotaOperationOutSchema:
  181. """
  182. 修改余额/点券
  183. 调用: alipay.ebpp.invoice.expensecontrol.quota.modify
  184. """
  185. crud = QuotaCRUD(auth)
  186. quota = await crud.get_by_out_biz_no(out_biz_no)
  187. if not quota:
  188. raise CustomException(msg="额度不存在")
  189. try:
  190. from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolQuotaModifyRequest import (
  191. AlipayEbppInvoiceExpensecontrolQuotaModifyRequest,
  192. )
  193. from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolQuotaModifyModel import (
  194. AlipayEbppInvoiceExpensecontrolQuotaModifyModel,
  195. )
  196. from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolQuotaModifyResponse import (
  197. AlipayEbppInvoiceExpensecontrolQuotaModifyResponse,
  198. )
  199. except ImportError:
  200. raise CustomException(msg="支付宝SDK未正确安装,请检查alipay-sdk-python依赖")
  201. model = AlipayEbppInvoiceExpensecontrolQuotaModifyModel()
  202. model.quota_id = data.quota_id
  203. model.action = data.action
  204. model.outer_source_id = data.outer_source_id
  205. model.enterprise_id = data.enterprise_id
  206. if data.amount is not None:
  207. model.amount = str(data.amount)
  208. if data.share_mode:
  209. model.share_mode = data.share_mode
  210. request = AlipayEbppInvoiceExpensecontrolQuotaModifyRequest()
  211. request.biz_model = model
  212. client = AlipayClient.get_client()
  213. response = client.execute(request)
  214. if not response:
  215. raise CustomException(msg="修改余额/点券失败: 无响应")
  216. result = AlipayEbppInvoiceExpensecontrolQuotaModifyResponse()
  217. result.parse_response_content(response)
  218. if not result.is_success():
  219. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  220. raise CustomException(msg=f"修改余额/点券失败: {result.msg}")
  221. return QuotaOperationOutSchema(
  222. out_biz_no=out_biz_no,
  223. quota_id=data.quota_id,
  224. result=result.success,
  225. )
  226. @classmethod
  227. async def delete_expense_quota_service(
  228. cls, auth: AuthSchema, out_biz_no: str, data: ExpenseQuotaDeleteSchema
  229. ) -> QuotaOperationOutSchema:
  230. """
  231. 删除额度
  232. 调用: alipay.ebpp.invoice.expensecontrol.quota.delete
  233. """
  234. crud = QuotaCRUD(auth)
  235. quota = await crud.get_by_out_biz_no(out_biz_no)
  236. if not quota:
  237. raise CustomException(msg="额度不存在")
  238. try:
  239. from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolQuotaDeleteRequest import (
  240. AlipayEbppInvoiceExpensecontrolQuotaDeleteRequest,
  241. )
  242. from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolQuotaDeleteModel import (
  243. AlipayEbppInvoiceExpensecontrolQuotaDeleteModel,
  244. )
  245. from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolQuotaDeleteResponse import (
  246. AlipayEbppInvoiceExpensecontrolQuotaDeleteResponse,
  247. )
  248. except ImportError:
  249. raise CustomException(msg="支付宝SDK未正确安装,请检查alipay-sdk-python依赖")
  250. model = AlipayEbppInvoiceExpensecontrolQuotaDeleteModel()
  251. model.enterprise_id = data.enterprise_id
  252. if data.quota_id:
  253. model.quota_id = data.quota_id
  254. if data.issue_batch_id:
  255. model.issue_batch_id = data.issue_batch_id
  256. request = AlipayEbppInvoiceExpensecontrolQuotaDeleteRequest()
  257. request.biz_model = model
  258. client = AlipayClient.get_client()
  259. response = client.execute(request)
  260. if not response:
  261. raise CustomException(msg="删除额度失败: 无响应")
  262. result = AlipayEbppInvoiceExpensecontrolQuotaDeleteResponse()
  263. result.parse_response_content(response)
  264. if not result.is_success():
  265. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  266. raise CustomException(msg=f"删除额度失败: {result.msg}")
  267. await crud.delete(id=quota.id)
  268. return QuotaOperationOutSchema(out_biz_no=out_biz_no)
  269. # ========================
  270. # 手工批量发放额度
  271. # ========================
  272. @classmethod
  273. async def issue_batch_create_service(
  274. cls, auth: AuthSchema, data: IssueBatchCreateSchema
  275. ) -> IssueBatchCreateOutSchema:
  276. """
  277. 手工批量发放额度
  278. 调用: alipay.ebpp.invoice.expensecontrol.issuebatch.create
  279. """
  280. try:
  281. from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolIssuebatchCreateRequest import (
  282. AlipayEbppInvoiceExpensecontrolIssuebatchCreateRequest,
  283. )
  284. from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolIssuebatchCreateModel import (
  285. AlipayEbppInvoiceExpensecontrolIssuebatchCreateModel,
  286. )
  287. from alipay.aop.api.domain.IssueTargetInfoContent import (
  288. IssueTargetInfoContent,
  289. )
  290. from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolIssuebatchCreateResponse import (
  291. AlipayEbppInvoiceExpensecontrolIssuebatchCreateResponse,
  292. )
  293. except ImportError:
  294. raise CustomException(msg="支付宝SDK未正确安装,请检查alipay-sdk-python依赖")
  295. # 本地检查批次号是否已存在,避免无效调用支付宝
  296. try:
  297. issue_batch_crud = IssueBatchCRUD(auth)
  298. existing_batch = await issue_batch_crud.get_by_batch_no(data.batch_no)
  299. if existing_batch:
  300. raise CustomException(msg=f"批次号 {data.batch_no} 已存在,请勿重复创建")
  301. except CustomException:
  302. raise
  303. except Exception:
  304. pass
  305. model = AlipayEbppInvoiceExpensecontrolIssuebatchCreateModel()
  306. model.enterprise_id = data.enterprise_id
  307. model.issue_name = data.issue_name
  308. model.quota_type = data.quota_type
  309. model.effective_start_date = data.effective_start_date
  310. model.effective_end_date = data.effective_end_date
  311. model.institution_id = data.institution_id
  312. model.batch_no = data.batch_no
  313. model.share_mode = data.share_mode
  314. if data.issue_desc:
  315. model.issue_desc = data.issue_desc
  316. if data.issue_target_info_list:
  317. target_list = []
  318. for item in data.issue_target_info_list:
  319. target = IssueTargetInfoContent()
  320. target.issue_quota = item.issue_quota
  321. if item.owner_open_id:
  322. target.owner_open_id = item.owner_open_id
  323. if item.owner_id:
  324. target.owner_id = item.owner_id
  325. if item.user_name:
  326. target.user_name = item.user_name
  327. if item.owner_type:
  328. target.owner_type = item.owner_type
  329. target_list.append(target)
  330. model.issue_target_info_list = target_list
  331. request = AlipayEbppInvoiceExpensecontrolIssuebatchCreateRequest()
  332. request.biz_model = model
  333. client = AlipayClient.get_client()
  334. response = client.execute(request)
  335. if not response:
  336. raise CustomException(msg="手工批量发放额度失败: 无响应")
  337. result = AlipayEbppInvoiceExpensecontrolIssuebatchCreateResponse()
  338. result.parse_response_content(response)
  339. if not result.is_success():
  340. sub_msg = getattr(result, 'sub_msg', '') or ''
  341. err_detail = f"{result.msg}" + (f" - {sub_msg}" if sub_msg else "")
  342. log.error(f"支付宝接口调用失败: {result.code} - {err_detail}")
  343. raise CustomException(msg=f"手工批量发放额度失败: {sub_msg or result.msg}")
  344. # 保存批次记录到本地
  345. try:
  346. issue_batch_crud = IssueBatchCRUD(auth)
  347. total_amount = Decimal("0")
  348. if data.issue_target_info_list:
  349. for item in data.issue_target_info_list:
  350. try:
  351. total_amount += Decimal(item.issue_quota)
  352. except Exception:
  353. pass
  354. batch_data = {
  355. "issue_batch_id": result.issue_batch_id,
  356. "batch_no": data.batch_no,
  357. "institution_id": data.institution_id,
  358. "issue_name": data.issue_name,
  359. "quota_type": data.quota_type,
  360. "share_mode": data.share_mode,
  361. "total_count": len(data.issue_target_info_list or []),
  362. "total_amount": total_amount,
  363. "status": "ACTIVE",
  364. "effective_start_date": datetime.strptime(data.effective_start_date, "%Y-%m-%d %H:%M:%S"),
  365. "effective_end_date": datetime.strptime(data.effective_end_date, "%Y-%m-%d %H:%M:%S"),
  366. "issue_desc": data.issue_desc,
  367. }
  368. await issue_batch_crud.create(batch_data)
  369. except Exception as e:
  370. import traceback
  371. log.warning(f"保存发放批次记录失败(不影响发放), 可检查: 1)batch_no是否重复 2)字段长度\n异常: {e}\n{traceback.format_exc()}")
  372. # 组装校验失败列表(在更新本地记录前获取,用于过滤)
  373. failed_owner_ids: set[str] = set()
  374. if hasattr(result, 'issue_quota_check_failed_list') and result.issue_quota_check_failed_list:
  375. failed_owner_ids = {
  376. str(getattr(f, 'owner_id', ''))
  377. for f in result.issue_quota_check_failed_list
  378. if getattr(f, 'owner_id', None)
  379. }
  380. # 为每个员工插入新的额度记录(每次发放独立记录,支持同一员工多次发放)
  381. try:
  382. from app.plugin.module_payment.expense.quota.model import QuotaModel
  383. from app.plugin.module_payment.expense.quota.enums import QuotaStatusEnum
  384. from sqlalchemy import insert, delete as sa_delete
  385. # 清理同一制度下 quota_id 为空的陈旧记录(来自较早失败/未完成的批次)
  386. try:
  387. clean_stmt = sa_delete(QuotaModel).where(
  388. QuotaModel.institution_id == data.institution_id,
  389. QuotaModel.quota_id.is_(None),
  390. )
  391. del_result = await auth.db.execute(clean_stmt)
  392. if del_result.rowcount:
  393. log.info(f"手工发放 - 清理 {del_result.rowcount} 条陈旧记录(institution_id={data.institution_id})")
  394. await auth.db.flush()
  395. except Exception as e:
  396. log.warning(f"手工发放 - 清理陈旧记录失败(不影响发放): {e}")
  397. tenant_id = auth.user.tenant_id if auth.user else 1
  398. effective_start = datetime.strptime(data.effective_start_date, "%Y-%m-%d %H:%M:%S") if data.effective_start_date else None
  399. effective_end = datetime.strptime(data.effective_end_date, "%Y-%m-%d %H:%M:%S") if data.effective_end_date else None
  400. inserted_count = 0
  401. if data.issue_target_info_list:
  402. for item in data.issue_target_info_list:
  403. emp_id = (item.owner_id or "")
  404. # 跳过支付宝校验失败的员工
  405. if emp_id in failed_owner_ids:
  406. log.warning(f"手工发放 - 跳过校验失败员工: owner_id={emp_id}")
  407. continue
  408. try:
  409. quota_amount = Decimal(item.issue_quota)
  410. except Exception:
  411. quota_amount = Decimal("0")
  412. # 每次都新增独立记录,不覆盖已有记录
  413. stmt = insert(QuotaModel).values(
  414. employee_id=emp_id,
  415. institution_id=data.institution_id,
  416. quota_type=data.quota_type,
  417. target_type="INSTITUTION",
  418. target_id=data.institution_id,
  419. out_biz_no=f"batch_{data.batch_no}_{emp_id}",
  420. total_amount=quota_amount,
  421. available_amount=quota_amount,
  422. status=QuotaStatusEnum.QUOTA_ACTIVE.value,
  423. valid_from=effective_start,
  424. valid_to=effective_end,
  425. enterprise_id=data.enterprise_id,
  426. tenant_id=tenant_id,
  427. )
  428. await auth.db.execute(stmt)
  429. inserted_count += 1
  430. await auth.db.flush()
  431. log.info(f"手工发放 - 新增 {inserted_count} 条额度记录(跳过 {len(failed_owner_ids)} 条校验失败)")
  432. # 查询支付宝端的发放记录,获取每个员工的 real quota_id 并更新本地记录
  433. try:
  434. from alipay.aop.api.request.AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryRequest import (
  435. AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryRequest,
  436. )
  437. from alipay.aop.api.domain.AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryModel import (
  438. AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryModel,
  439. )
  440. from alipay.aop.api.response.AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryResponse import (
  441. AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryResponse,
  442. )
  443. records_model = AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryModel()
  444. records_model.enterprise_id = data.enterprise_id
  445. records_model.institution_id = data.institution_id
  446. records_model.issue_batch_id = result.issue_batch_id
  447. records_model.page_size = len(data.issue_target_info_list)
  448. records_model.page_num = 1
  449. records_request = AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryRequest()
  450. records_request.biz_model = records_model
  451. records_response = client.execute(records_request)
  452. if records_response:
  453. records_result = AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryResponse()
  454. records_result.parse_response_content(records_response)
  455. if records_result.is_success() and hasattr(records_result, 'issue_record_info_list') and records_result.issue_record_info_list:
  456. updated_quota_id_count = 0
  457. for r in records_result.issue_record_info_list:
  458. quota_id = getattr(r, 'quota_id', None)
  459. owner_id = getattr(r, 'owner_id', None)
  460. if quota_id and owner_id:
  461. # 按 out_biz_no 模式匹配,只更新本批次插入的记录
  462. q_upd = sa_update(QuotaModel).where(
  463. QuotaModel.out_biz_no == f"batch_{data.batch_no}_{owner_id}",
  464. QuotaModel.institution_id == data.institution_id,
  465. ).values(quota_id=quota_id)
  466. await auth.db.execute(q_upd)
  467. updated_quota_id_count += 1
  468. if updated_quota_id_count:
  469. await auth.db.flush()
  470. log.info(f"手工发放 - 已从支付宝查询并更新 {updated_quota_id_count} 条记录的 quota_id")
  471. except Exception as e:
  472. log.warning(f"查询支付宝发放记录获取 quota_id 失败(不影响发放): {e}")
  473. except Exception as e:
  474. import traceback
  475. log.warning(f"保存额度记录到本地失败(不影响发放), 可检查: 1)tenant_id/enterprise_id 2)字段长度\n异常: {e}\n{traceback.format_exc()}")
  476. # 组装校验失败列表
  477. failed_list = None
  478. if hasattr(result, 'issue_quota_check_failed_list') and result.issue_quota_check_failed_list:
  479. failed_list = []
  480. for f in result.issue_quota_check_failed_list:
  481. failed_list.append(IssueQuotaCheckFailedItem(
  482. user_name=getattr(f, 'user_name', None),
  483. owner_type=getattr(f, 'owner_type', None),
  484. owner_id=getattr(f, 'owner_id', None),
  485. owner_open_id=getattr(f, 'owner_open_id', None),
  486. issue_quota=getattr(f, 'issue_quota', None),
  487. message=getattr(f, 'message', None),
  488. result=getattr(f, 'result', None),
  489. ))
  490. return IssueBatchCreateOutSchema(
  491. issue_batch_id=result.issue_batch_id,
  492. issue_quota_check_failed_list=failed_list,
  493. )
  494. @classmethod
  495. async def issue_batch_cancel_service(
  496. cls, auth: AuthSchema, data: IssueBatchCancelSchema
  497. ) -> IssueBatchCancelOutSchema:
  498. """
  499. 作废手工发放批次
  500. 调用: alipay.ebpp.invoice.expensecontrol.issuebatch.cancel
  501. """
  502. try:
  503. from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolIssuebatchCancelRequest import (
  504. AlipayEbppInvoiceExpensecontrolIssuebatchCancelRequest,
  505. )
  506. from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolIssuebatchCancelModel import (
  507. AlipayEbppInvoiceExpensecontrolIssuebatchCancelModel,
  508. )
  509. from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolIssuebatchCancelResponse import (
  510. AlipayEbppInvoiceExpensecontrolIssuebatchCancelResponse,
  511. )
  512. except ImportError:
  513. raise CustomException(msg="支付宝SDK未正确安装,请检查alipay-sdk-python依赖")
  514. model = AlipayEbppInvoiceExpensecontrolIssuebatchCancelModel()
  515. model.enterprise_id = data.enterprise_id
  516. model.institution_id = data.institution_id
  517. model.issue_batch_id = data.issue_batch_id
  518. request = AlipayEbppInvoiceExpensecontrolIssuebatchCancelRequest()
  519. request.biz_model = model
  520. client = AlipayClient.get_client()
  521. response = client.execute(request)
  522. if not response:
  523. raise CustomException(msg="作废手工发放批次失败: 无响应")
  524. result = AlipayEbppInvoiceExpensecontrolIssuebatchCancelResponse()
  525. result.parse_response_content(response)
  526. if not result.is_success():
  527. sub_msg = getattr(result, 'sub_msg', '') or ''
  528. err_detail = f"{result.msg}" + (f" - {sub_msg}" if sub_msg else "")
  529. log.error(f"支付宝接口调用失败: {result.code} - {err_detail}")
  530. raise CustomException(msg=f"作废手工发放批次失败: {sub_msg or result.msg}")
  531. # 更新本地批次记录状态
  532. try:
  533. issue_batch_crud = IssueBatchCRUD(auth)
  534. batch = await issue_batch_crud.get_by_issue_batch_id(data.issue_batch_id)
  535. if batch:
  536. setattr(batch, 'status', 'CANCELLED')
  537. await issue_batch_crud.update(batch.id, {"status": "CANCELLED"})
  538. except Exception as e:
  539. log.warning(f"更新批次本地状态失败(不影响作废): {e}")
  540. # 作废批次后,删除该批次创建的额度记录
  541. try:
  542. from app.plugin.module_payment.expense.quota.model import QuotaModel
  543. from sqlalchemy import delete as sa_delete
  544. # 从本地批次记录获取 batch_no
  545. issue_batch_crud = IssueBatchCRUD(auth)
  546. batch = await issue_batch_crud.get_by_issue_batch_id(data.issue_batch_id)
  547. batch_no = batch.batch_no if batch else None
  548. if batch_no:
  549. # 按 out_biz_no 模式匹配: batch_{batch_no}_%
  550. pattern = f"batch_{batch_no}_%"
  551. del_stmt = sa_delete(QuotaModel).where(
  552. QuotaModel.out_biz_no.like(pattern),
  553. QuotaModel.institution_id == data.institution_id,
  554. )
  555. await auth.db.execute(del_stmt)
  556. await auth.db.flush()
  557. log.info(f"批次作废 - 已删除该批次创建的额度记录: batch_no={batch_no}")
  558. except Exception as e:
  559. log.warning(f"删除额度记录失败(不影响作废): {e}")
  560. return IssueBatchCancelOutSchema(
  561. result=getattr(result, 'result', False),
  562. )
  563. return IssueBatchCancelOutSchema(
  564. result=getattr(result, 'result', False),
  565. )
  566. @classmethod
  567. async def issue_batch_records_query_service(
  568. cls, auth: AuthSchema, data: IssueBatchRecordsQuerySchema
  569. ) -> IssueBatchRecordsQueryOutSchema:
  570. """
  571. 查询手工发放发放明细
  572. 调用: alipay.ebpp.invoice.issuebatch.issuerecords.batchquery
  573. """
  574. try:
  575. from alipay.aop.api.request.AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryRequest import (
  576. AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryRequest,
  577. )
  578. from alipay.aop.api.domain.AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryModel import (
  579. AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryModel,
  580. )
  581. from alipay.aop.api.response.AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryResponse import (
  582. AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryResponse,
  583. )
  584. except ImportError:
  585. raise CustomException(msg="支付宝SDK未正确安装,请检查alipay-sdk-python依赖")
  586. model = AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryModel()
  587. model.enterprise_id = data.enterprise_id
  588. model.institution_id = data.institution_id
  589. model.issue_batch_id = data.issue_batch_id
  590. model.page_size = data.page_size
  591. model.page_num = data.page_num
  592. request = AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryRequest()
  593. request.biz_model = model
  594. client = AlipayClient.get_client()
  595. response = client.execute(request)
  596. if not response:
  597. raise CustomException(msg="查询手工发放明细失败: 无响应")
  598. result = AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryResponse()
  599. result.parse_response_content(response)
  600. if not result.is_success():
  601. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  602. raise CustomException(msg=f"查询手工发放明细失败: {result.msg}")
  603. record_list = None
  604. if hasattr(result, 'issue_record_info_list') and result.issue_record_info_list:
  605. record_list = []
  606. for r in result.issue_record_info_list:
  607. record_list.append(IssueRecordInfoItem(
  608. quota_id=getattr(r, 'quota_id', None),
  609. issue_quota=getattr(r, 'issue_quota', None),
  610. issue_status=getattr(r, 'issue_status', None),
  611. owner_type=getattr(r, 'owner_type', None),
  612. owner_id=getattr(r, 'owner_id', None),
  613. owner_open_id=getattr(r, 'owner_open_id', None),
  614. user_name=getattr(r, 'user_name', None),
  615. currency=getattr(r, 'currency', None),
  616. ))
  617. return IssueBatchRecordsQueryOutSchema(
  618. page_num=getattr(result, 'page_num', data.page_num),
  619. page_size=getattr(result, 'page_size', data.page_size),
  620. total_page_count=getattr(result, 'total_page_count', 0),
  621. issue_record_info_list=record_list,
  622. )
  623. @classmethod
  624. async def list_batch_service(
  625. cls,
  626. auth: AuthSchema,
  627. page_no: int = 1,
  628. page_size: int = 20,
  629. search: dict | None = None,
  630. ) -> dict:
  631. """分页查询手工发放批次列表(本地DB)"""
  632. crud = IssueBatchCRUD(auth)
  633. offset = (page_no - 1) * page_size
  634. return await crud.page(
  635. offset=offset,
  636. limit=page_size,
  637. order_by=[{"id": "desc"}],
  638. search=search or {},
  639. out_schema=IssueBatchListOutSchema,
  640. )
  641. @classmethod
  642. async def list_employee_quota_records_service(
  643. cls,
  644. auth: AuthSchema,
  645. employee_id: str,
  646. institution_id: str | None = None,
  647. ) -> list[dict]:
  648. """查询员工的额度记录列表"""
  649. from app.plugin.module_payment.expense.quota.model import QuotaModel
  650. from sqlalchemy import select
  651. where = [QuotaModel.employee_id == employee_id]
  652. if institution_id:
  653. where.append(QuotaModel.institution_id == institution_id)
  654. stmt = select(QuotaModel).where(*where).order_by(QuotaModel.created_time.desc())
  655. result = await auth.db.execute(stmt)
  656. quotas = result.scalars().all()
  657. return [
  658. {
  659. "quota_id": q.quota_id,
  660. "out_biz_no": q.out_biz_no,
  661. "total_amount": float(q.total_amount) if q.total_amount else 0,
  662. "available_amount": float(q.available_amount) if q.available_amount else 0,
  663. "quota_type": q.quota_type,
  664. "status": q.status,
  665. "valid_from": q.valid_from,
  666. "valid_to": q.valid_to,
  667. "created_time": q.created_time,
  668. "institution_id": q.institution_id,
  669. }
  670. for q in quotas
  671. ]
  672. @classmethod
  673. async def adjust_quota_service(
  674. cls, auth: AuthSchema, data: AdjustQuotaSchema
  675. ) -> dict:
  676. """调整额度金额 (调Alipay quota.modify + 记录变更日志)"""
  677. from .crud import QuotaChangeLogCRUD
  678. from .model import QuotaModel
  679. from sqlalchemy import select, update as sa_update
  680. # 查询当前额度记录(支持按 quota_id 或 id 查找)
  681. stmt = select(QuotaModel).where(QuotaModel.quota_id == data.quota_id)
  682. result = await auth.db.execute(stmt)
  683. quota = result.scalar_one_or_none()
  684. if not quota:
  685. # quota_id 为空时尝试按数据库 id 查找
  686. try:
  687. local_id = int(data.quota_id)
  688. stmt = select(QuotaModel).where(QuotaModel.id == local_id)
  689. result = await auth.db.execute(stmt)
  690. quota = result.scalar_one_or_none()
  691. except (ValueError, TypeError):
  692. pass
  693. if not quota:
  694. raise CustomException(msg="额度记录不存在")
  695. # 如果本地 quota_id 为空,尝试从 Alipay 获取真实 quota_id
  696. alipay_quota_id = quota.quota_id
  697. if not alipay_quota_id and quota.out_biz_no:
  698. try:
  699. from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolQuotaQueryRequest import (
  700. AlipayEbppInvoiceExpensecontrolQuotaQueryRequest,
  701. )
  702. from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolQuotaQueryModel import (
  703. AlipayEbppInvoiceExpensecontrolQuotaQueryModel,
  704. )
  705. from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolQuotaQueryResponse import (
  706. AlipayEbppInvoiceExpensecontrolQuotaQueryResponse,
  707. )
  708. query_model = AlipayEbppInvoiceExpensecontrolQuotaQueryModel()
  709. query_model.enterprise_id = quota.enterprise_id or data.enterprise_id
  710. query_model.owner_id = quota.employee_id
  711. query_model.owner_type = "ENTERPRISE_PAY_UID"
  712. query_model.page_num = 1
  713. query_model.page_size = 10
  714. query_request = AlipayEbppInvoiceExpensecontrolQuotaQueryRequest()
  715. query_request.biz_model = query_model
  716. client = AlipayClient.get_client()
  717. query_response = client.execute(query_request)
  718. if query_response:
  719. query_result = AlipayEbppInvoiceExpensecontrolQuotaQueryResponse()
  720. query_result.parse_response_content(query_response)
  721. if query_result.is_success() and hasattr(query_result, 'quota_detail_info_list') and query_result.quota_detail_info_list:
  722. for q in query_result.quota_detail_info_list:
  723. qid = getattr(q, 'quota_id', None)
  724. if qid:
  725. alipay_quota_id = qid
  726. # 回写本地
  727. upd = sa_update(QuotaModel).where(QuotaModel.id == quota.id).values(quota_id=qid)
  728. await auth.db.execute(upd)
  729. await auth.db.flush()
  730. log.info(f"从支付宝回填 quota_id: id={quota.id}, quota_id={qid}")
  731. break
  732. except Exception as e:
  733. log.warning(f"从支付宝获取真实 quota_id 失败: {e}")
  734. if not alipay_quota_id:
  735. raise CustomException(msg="该额度暂未关联支付宝额度ID,无法调整(请等待支付宝同步后重试)")
  736. current_available = float(quota.available_amount) if quota.available_amount else 0
  737. diff = round(data.amount - current_available, 2)
  738. outer_source_id = str(get_snowflake_id())
  739. # 调Alipay quota.modify
  740. try:
  741. from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolQuotaModifyRequest import (
  742. AlipayEbppInvoiceExpensecontrolQuotaModifyRequest,
  743. )
  744. from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolQuotaModifyModel import (
  745. AlipayEbppInvoiceExpensecontrolQuotaModifyModel,
  746. )
  747. from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolQuotaModifyResponse import (
  748. AlipayEbppInvoiceExpensecontrolQuotaModifyResponse,
  749. )
  750. except ImportError:
  751. raise CustomException(msg="支付宝SDK未正确安装")
  752. model = AlipayEbppInvoiceExpensecontrolQuotaModifyModel()
  753. model.quota_id = alipay_quota_id
  754. model.action = "ADD" if diff >= 0 else "DEDUCT"
  755. model.outer_source_id = outer_source_id
  756. model.enterprise_id = data.enterprise_id
  757. model.amount = str(int(abs(diff) * 100))
  758. request = AlipayEbppInvoiceExpensecontrolQuotaModifyRequest()
  759. request.biz_model = model
  760. client = AlipayClient.get_client()
  761. response = client.execute(request)
  762. if not response:
  763. raise CustomException(msg="调整额度失败: 无响应")
  764. mod_result = AlipayEbppInvoiceExpensecontrolQuotaModifyResponse()
  765. mod_result.parse_response_content(response)
  766. if not mod_result.is_success():
  767. sub_msg = getattr(mod_result, 'sub_msg', '') or ''
  768. sub_code = getattr(mod_result, 'sub_code', '') or ''
  769. log.error(f"支付宝接口调用失败: {mod_result.code} - {mod_result.msg} (sub_code={sub_code}, sub_msg={sub_msg})")
  770. # 如果 Alipay 提示额度不存在,清除本地错误的 quota_id(可能是 issue_rule_id 误存),后续可重试查询回填
  771. if ('不存在' in sub_msg or 'INVALID' in sub_code) and quota:
  772. try:
  773. upd = sa_update(QuotaModel).where(QuotaModel.id == quota.id).values(quota_id=None)
  774. await auth.db.execute(upd)
  775. await auth.db.flush()
  776. log.info(f"调整额度 - 本地quota_id无效({data.quota_id})已清除,后续可从支付宝查询回填")
  777. except Exception as e:
  778. log.warning(f"清除本地无效quota_id失败: {e}")
  779. raise CustomException(msg=f"调整额度失败: {sub_msg or mod_result.msg}")
  780. # 更新本地额度记录
  781. new_available = data.amount
  782. new_total = float(quota.total_amount) if quota.total_amount else 0
  783. if diff > 0:
  784. new_total += diff
  785. else:
  786. new_total = max(0, new_total + diff)
  787. upd = sa_update(QuotaModel).where(
  788. QuotaModel.quota_id == data.quota_id
  789. ).values(
  790. total_amount=new_total,
  791. available_amount=new_available,
  792. )
  793. await auth.db.execute(upd)
  794. await auth.db.flush()
  795. # 记录变更日志
  796. try:
  797. log_crud = QuotaChangeLogCRUD(auth)
  798. await log_crud.create({
  799. "quota_id": data.quota_id,
  800. "employee_id": quota.employee_id or "",
  801. "institution_id": quota.institution_id or "",
  802. "change_type": "ADJUST",
  803. "coupon_name": quota.out_biz_no or "额度调整",
  804. "change_amount": diff,
  805. "before_amount": current_available,
  806. "after_amount": new_available,
  807. "change_desc": data.change_desc or "",
  808. "enterprise_id": data.enterprise_id,
  809. "tenant_id": auth.user.tenant_id if auth.user else 1,
  810. })
  811. except Exception as e:
  812. log.warning(f"记录变更日志失败(不影响调整): {e}")
  813. return {
  814. "quota_id": data.quota_id,
  815. "before_amount": current_available,
  816. "after_amount": new_available,
  817. "diff": diff,
  818. }
  819. @classmethod
  820. async def list_quota_changes_service(
  821. cls, auth: AuthSchema, quota_id: str
  822. ) -> list[dict]:
  823. """查询额度的变更记录"""
  824. from .crud import QuotaChangeLogCRUD
  825. crud = QuotaChangeLogCRUD(auth)
  826. logs = await crud.list(search={"quota_id": quota_id}, order_by=[{"id": "desc"}])
  827. return [
  828. {
  829. "coupon_name": log.coupon_name,
  830. "change_time": log.created_time,
  831. "change_amount": float(log.change_amount) if log.change_amount else 0,
  832. "change_desc": log.change_desc,
  833. "change_type": log.change_type,
  834. }
  835. for log in (logs or [])
  836. ]
  837. @classmethod
  838. async def list_service(
  839. cls,
  840. auth: AuthSchema,
  841. page_no: int = 1,
  842. page_size: int = 20,
  843. search: dict | None = None,
  844. ) -> dict:
  845. crud = QuotaCRUD(auth)
  846. offset = (page_no - 1) * page_size
  847. result = await crud.page(
  848. offset=offset,
  849. limit=page_size,
  850. order_by=[{"id": "desc"}],
  851. search=search or {},
  852. out_schema=QuotaListOutSchema,
  853. )
  854. # 如果指定了 institution_id,同时查询支付宝端自动发放的额度
  855. if search and search.get("institution_id"):
  856. try:
  857. from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolQuotaQueryRequest import (
  858. AlipayEbppInvoiceExpensecontrolQuotaQueryRequest,
  859. )
  860. from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolQuotaQueryModel import (
  861. AlipayEbppInvoiceExpensecontrolQuotaQueryModel,
  862. )
  863. from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolQuotaQueryResponse import (
  864. AlipayEbppInvoiceExpensecontrolQuotaQueryResponse,
  865. )
  866. alipay_model = AlipayEbppInvoiceExpensecontrolQuotaQueryModel()
  867. # owner_type 为必填字段,查询制度下所有额度时使用通用类型
  868. alipay_model.owner_type = "ENTERPRISE_PAY_UID"
  869. alipay_model.target_type = "INSTITUTION"
  870. alipay_model.target_id = search["institution_id"]
  871. alipay_model.page_size = 100
  872. alipay_model.page_num = 1
  873. request = AlipayEbppInvoiceExpensecontrolQuotaQueryRequest()
  874. request.biz_model = alipay_model
  875. client = AlipayClient.get_client()
  876. response = client.execute(request)
  877. if response:
  878. alipay_result = AlipayEbppInvoiceExpensecontrolQuotaQueryResponse()
  879. alipay_result.parse_response_content(response)
  880. if alipay_result.is_success() and hasattr(alipay_result, 'quota_detail_info_list') and alipay_result.quota_detail_info_list:
  881. # 将支付宝端额度合并到结果中(去重)
  882. existing_ids = {item.get("quota_id") for item in result.get("items", []) if item.get("quota_id")}
  883. for q in alipay_result.quota_detail_info_list:
  884. qid = getattr(q, 'quota_id', None)
  885. if qid and qid not in existing_ids:
  886. result["items"].append({
  887. "quota_id": qid,
  888. "target_type": getattr(q, 'target_type', None),
  889. "target_id": getattr(q, 'target_id', None),
  890. "quota_type": getattr(q, 'quota_type', None),
  891. "employee_id": getattr(q, 'owner_id', None),
  892. "total_amount": getattr(q, 'total_amount', None),
  893. "available_amount": getattr(q, 'available_amount', None),
  894. "status": getattr(q, 'status', "QUOTA_ACTIVE"),
  895. "created_time": getattr(q, 'effective_start_date', None),
  896. })
  897. existing_ids.add(qid)
  898. result["total"] = len(result["items"])
  899. except Exception as e:
  900. log.warning(f"查询支付宝端额度失败(不影响本地数据): {e}")
  901. return result