service.py 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064
  1. import asyncio
  2. from datetime import datetime
  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.plugin.module_payment.expense.institution.schema import InstitutionListOutSchema, InstitutionCreateSchema
  8. from .crud import InstitutionCRUD
  9. from .enums import InstitutionStatusEnum
  10. from alipay.aop.api.request.AlipayEbppInvoiceInstitutionCreateRequest import (
  11. AlipayEbppInvoiceInstitutionCreateRequest,
  12. )
  13. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionCreateModel import (
  14. AlipayEbppInvoiceInstitutionCreateModel,
  15. )
  16. from alipay.aop.api.response.AlipayEbppInvoiceInstitutionCreateResponse import (
  17. AlipayEbppInvoiceInstitutionCreateResponse,
  18. )
  19. from alipay.aop.api.request.AlipayEbppInvoiceInstitutionPageinfoQueryRequest import (
  20. AlipayEbppInvoiceInstitutionPageinfoQueryRequest,
  21. )
  22. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionPageinfoQueryModel import (
  23. AlipayEbppInvoiceInstitutionPageinfoQueryModel,
  24. )
  25. from alipay.aop.api.response.AlipayEbppInvoiceInstitutionPageinfoQueryResponse import (
  26. AlipayEbppInvoiceInstitutionPageinfoQueryResponse,
  27. )
  28. from alipay.aop.api.request.AlipayEbppInvoiceInstitutionDetailinfoQueryRequest import (
  29. AlipayEbppInvoiceInstitutionDetailinfoQueryRequest,
  30. )
  31. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionDetailinfoQueryModel import (
  32. AlipayEbppInvoiceInstitutionDetailinfoQueryModel,
  33. )
  34. from alipay.aop.api.response.AlipayEbppInvoiceInstitutionDetailinfoQueryResponse import (
  35. AlipayEbppInvoiceInstitutionDetailinfoQueryResponse,
  36. )
  37. from alipay.aop.api.request.AlipayEbppInvoiceInstitutionDeleteRequest import (
  38. AlipayEbppInvoiceInstitutionDeleteRequest,
  39. )
  40. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionDeleteModel import (
  41. AlipayEbppInvoiceInstitutionDeleteModel,
  42. )
  43. from alipay.aop.api.response.AlipayEbppInvoiceInstitutionDeleteResponse import (
  44. AlipayEbppInvoiceInstitutionDeleteResponse,
  45. )
  46. from alipay.aop.api.request.AlipayEbppInvoiceInstitutionModifyRequest import (
  47. AlipayEbppInvoiceInstitutionModifyRequest,
  48. )
  49. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionModifyModel import (
  50. AlipayEbppInvoiceInstitutionModifyModel,
  51. )
  52. from alipay.aop.api.response.AlipayEbppInvoiceInstitutionModifyResponse import (
  53. AlipayEbppInvoiceInstitutionModifyResponse,
  54. )
  55. class InstitutionService:
  56. """费控制度服务层"""
  57. @classmethod
  58. def _execute_alipay(cls, request):
  59. """同步执行支付宝调用(通过线程池避免阻塞事件循环)"""
  60. client = AlipayClient.get_client()
  61. return client.execute(request)
  62. @classmethod
  63. async def create_institution_service(
  64. cls, auth: AuthSchema, data: AlipayEbppInvoiceInstitutionCreateModel
  65. ) -> AlipayEbppInvoiceInstitutionCreateResponse:
  66. """
  67. 创建费控制度(仅调 institution.create,不包含串联流程)
  68. 调用: alipay.ebpp.invoice.institution.create
  69. """
  70. if data.enterprise_id is None:
  71. raise CustomException(msg="创建费控制度失败: 企业ID不能为空")
  72. data.currency = 'CNY'
  73. request = AlipayEbppInvoiceInstitutionCreateRequest()
  74. request.biz_model = data
  75. response = await asyncio.to_thread(cls._execute_alipay, request)
  76. if not response:
  77. raise CustomException(msg="创建费控制度失败: 无响应")
  78. result = AlipayEbppInvoiceInstitutionCreateResponse()
  79. result.parse_response_content(response)
  80. if not result.is_success():
  81. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  82. raise CustomException(msg=f"创建费控制度失败: {result.msg}")
  83. return result
  84. @classmethod
  85. async def create_institution_full_flow(
  86. cls,
  87. auth: AuthSchema,
  88. institution_model: AlipayEbppInvoiceInstitutionCreateModel,
  89. enterprise_id: str,
  90. scope_data: dict | None = None,
  91. issuerule_data: dict | None = None,
  92. raw_data: dict | None = None,
  93. ) -> dict:
  94. """
  95. 创建费控制度(完整串联流程)
  96. 流程:
  97. 1. institution.create → 获取 institution_id
  98. 2. scope.modify ← 如有适用成员数据(scope_data)
  99. 3. issuerule.create ← 如为"按固定周期发放"(issuerule_data)
  100. 4. 保存到本地DB(制度 + 使用规则 + 发放规则)
  101. """
  102. # 第1步:创建制度
  103. institution_result = await cls.create_institution_service(auth=auth, data=institution_model)
  104. institution_id = institution_result.institution_id
  105. try:
  106. # 第2步:设置适用成员(如有)
  107. scope_modified = False
  108. if scope_data and scope_data.get("adapter_type") and scope_data.get("adapter_type") != "NONE":
  109. await InstitutionScopeService.scope_modify_service(
  110. auth=auth,
  111. institution_id=institution_id,
  112. data={
  113. "enterprise_id": enterprise_id,
  114. "adapter_type": scope_data["adapter_type"],
  115. "owner_type": scope_data.get("owner_type"),
  116. "add_owner_id_list": scope_data.get("add_owner_id_list"),
  117. },
  118. )
  119. scope_modified = True
  120. log.info(f"成员设置成功: institution_id={institution_id}")
  121. # 第3步:创建自动发放规则(如为"按固定周期发放")
  122. issue_rule_id = None
  123. if issuerule_data:
  124. issuerule_result = await IssueruleService.create_issuerule_service(
  125. auth=auth,
  126. institution_id=institution_id,
  127. enterprise_id=enterprise_id,
  128. quota_type=issuerule_data.get("quota_type", "CAP"),
  129. issue_type=issuerule_data.get("issue_type", "ISSUE_MONTH"),
  130. issue_amount_value=issuerule_data.get("issue_amount_value", "0"),
  131. outer_source_id=issuerule_data.get("outer_source_id"),
  132. issue_rule_name=issuerule_data.get("issue_rule_name"),
  133. effective_period=issuerule_data.get("effective_period"),
  134. invalid_mode=issuerule_data.get("invalid_mode", 0),
  135. share_mode=issuerule_data.get("share_mode", 0),
  136. )
  137. issue_rule_id = issuerule_result.get("issue_rule_id")
  138. log.info(f"发放规则创建成功: institution_id={institution_id}, issue_rule_id={issue_rule_id}")
  139. except Exception as e:
  140. # 子步骤失败:删除已创建的支付宝制度(补偿事务)
  141. log.error(f"创建串联流程失败: {e},开始回滚 institution_id={institution_id}")
  142. try:
  143. from alipay.aop.api.request.AlipayEbppInvoiceInstitutionDeleteRequest import (
  144. AlipayEbppInvoiceInstitutionDeleteRequest,
  145. )
  146. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionDeleteModel import (
  147. AlipayEbppInvoiceInstitutionDeleteModel,
  148. )
  149. rollback_model = AlipayEbppInvoiceInstitutionDeleteModel()
  150. rollback_model.institution_id = institution_id
  151. rollback_model.enterprise_id = enterprise_id
  152. req = AlipayEbppInvoiceInstitutionDeleteRequest()
  153. req.biz_model = rollback_model
  154. await asyncio.to_thread(cls._execute_alipay, req)
  155. log.info(f"回滚成功: 已删除 institution_id={institution_id}")
  156. except Exception as rollback_err:
  157. log.error(f"回滚失败: {rollback_err}")
  158. raise
  159. # 第4步:保存到本地DB
  160. create_data = InstitutionCreateSchema(
  161. enterprise_id=enterprise_id,
  162. institution_id=institution_id,
  163. institution_name=getattr(institution_model, 'institution_name', None),
  164. institution_desc=getattr(institution_model, 'institution_desc', None),
  165. scene_type=getattr(institution_model, 'scene_type', None),
  166. expense_type=getattr(institution_model, 'expense_type', None),
  167. expense_sub_type=getattr(institution_model, 'expense_sub_type', None),
  168. status=InstitutionStatusEnum.INSTITUTION_CREATE.value,
  169. effective=getattr(institution_model, 'effective', None),
  170. effective_start_date=getattr(institution_model, 'effective_start_date', None),
  171. effective_end_date=getattr(institution_model, 'effective_end_date', None),
  172. consult_mode=getattr(institution_model, 'consult_mode', None),
  173. multi_employee_share_mode=getattr(institution_model, 'multi_employee_share_mode', None),
  174. currency=getattr(institution_model, 'currency', None),
  175. grant_mode=(raw_data or {}).get("grant_mode"),
  176. period_type=(raw_data or {}).get("period_type"),
  177. amount=(raw_data or {}).get("amount"),
  178. single_limit=(raw_data or {}).get("single_limit"),
  179. effective_time_type=(raw_data or {}).get("effective_time_type"),
  180. applicable_scope=(raw_data or {}).get("applicable_scope"),
  181. )
  182. create_data_dict = create_data.model_dump(exclude_unset=True)
  183. crud = InstitutionCRUD(auth)
  184. await crud.create(create_data_dict)
  185. # 第5步:保存使用规则到本地
  186. if raw_data and raw_data.get("standard_info_list") and hasattr(institution_result, 'standard_id_info_list') and institution_result.standard_id_info_list:
  187. from app.plugin.module_payment.expense.rule.crud import RuleCRUD
  188. from app.plugin.module_payment.expense.rule.service import RuleService
  189. standard_id_map = {}
  190. for info in institution_result.standard_id_info_list:
  191. if hasattr(info, 'outer_source_id') and hasattr(info, 'standard_id'):
  192. standard_id_map[info.outer_source_id] = info.standard_id
  193. for idx, std in enumerate(raw_data["standard_info_list"]):
  194. condition_list = std.get("standard_condition_info_list", [])
  195. single_limit_val = None
  196. for cond in (condition_list or []):
  197. if cond.get("rule_factor") == "QUOTA_TOTAL":
  198. try:
  199. single_limit_val = float(cond.get("rule_value", 0))
  200. except (ValueError, TypeError):
  201. pass
  202. std_data = {
  203. "out_biz_no": std.get("outer_source_id", f"std_{institution_id}_{idx}"),
  204. "institution_id": institution_id,
  205. "rule_id": standard_id_map.get(std.get("outer_source_id", "")),
  206. "standard_name": std.get("standard_name"),
  207. "standard_desc": std.get("standard_desc"),
  208. "expense_type_sub_category": std.get("expense_type_sub_category", "DEFAULT"),
  209. "enterprise_id": enterprise_id,
  210. "tenant_id": auth.user.tenant_id if auth.user else 1,
  211. "condition_info": condition_list,
  212. "single_limit": single_limit_val,
  213. }
  214. try:
  215. from app.plugin.module_payment.expense.rule.model import ExpenseRuleModel
  216. from sqlalchemy import insert
  217. stmt = insert(ExpenseRuleModel).values(**std_data)
  218. await auth.db.execute(stmt)
  219. await auth.db.flush()
  220. except Exception as e:
  221. log.warning(f"保存使用规则到本地失败: {e}")
  222. # 第6步:保存发放规则到本地
  223. if issuerule_data and issue_rule_id:
  224. from app.plugin.module_payment.expense.quota.model import QuotaModel
  225. # 根据适用范围确定 employee_id
  226. scope = (raw_data or {}).get("applicable_scope", "")
  227. emp_id = "ALL"
  228. if scope == "employee":
  229. owner_ids = (raw_data or {}).get("scope_owner_id_list") or []
  230. emp_id = owner_ids[0] if owner_ids else "ALL"
  231. elif scope == "department":
  232. dept_id = (raw_data or {}).get("department_id", "")
  233. emp_id = f"DEPT:{dept_id}" if dept_id else "ALL"
  234. quota_save_data = {
  235. "employee_id": emp_id,
  236. "institution_id": institution_id,
  237. "out_biz_no": issuerule_data.get("outer_source_id", f"issue_{institution_id}"),
  238. "quota_id": issue_rule_id,
  239. "total_amount": float(issuerule_data.get("issue_amount_value", 0)),
  240. "available_amount": float(issuerule_data.get("issue_amount_value", 0)),
  241. "status": "QUOTA_ACTIVE",
  242. "enterprise_id": enterprise_id,
  243. "tenant_id": auth.user.tenant_id if auth.user else 1,
  244. }
  245. try:
  246. from sqlalchemy import insert
  247. stmt = insert(QuotaModel).values(**quota_save_data)
  248. await auth.db.execute(stmt)
  249. await auth.db.flush()
  250. except Exception as e:
  251. log.warning(f"保存发放规则到本地失败: {e}")
  252. return {
  253. "institution_id": institution_id,
  254. "scope_modified": scope_modified,
  255. "issue_rule_id": issue_rule_id,
  256. }
  257. @classmethod
  258. async def pageinfo_query_service(
  259. cls,
  260. auth: AuthSchema,
  261. enterprise_id: str,
  262. page_no: int = 1,
  263. page_size: int = 20,
  264. institution_name: str | None = None,
  265. ) -> dict:
  266. """
  267. 从支付宝查询费控制度列表
  268. 调用: alipay.ebpp.invoice.institution.pageinfo.query
  269. 失败时降级到本地DB
  270. """
  271. try:
  272. model = AlipayEbppInvoiceInstitutionPageinfoQueryModel()
  273. model.enterprise_id = enterprise_id
  274. model.page_num = page_no
  275. model.page_size = page_size
  276. if institution_name:
  277. model.institution_name = institution_name
  278. req = AlipayEbppInvoiceInstitutionPageinfoQueryRequest()
  279. req.biz_model = model
  280. response = await asyncio.to_thread(cls._execute_alipay, req)
  281. if response:
  282. result = AlipayEbppInvoiceInstitutionPageinfoQueryResponse()
  283. result.parse_response_content(response)
  284. if result.is_success():
  285. return {
  286. "page_no": getattr(result, 'page_num', page_no) or page_no,
  287. "page_size": getattr(result, 'page_size', page_size) or page_size,
  288. "total": getattr(result, 'total_page_count', 0) or 0,
  289. "list": getattr(result, 'institution_list', []) or [],
  290. }
  291. log.warning("支付宝 pageinfo.query 失败,降级到本地DB")
  292. except Exception as e:
  293. log.warning(f"支付宝 pageinfo.query 异常: {e},降级到本地DB")
  294. # 降级:查本地DB
  295. crud = InstitutionCRUD(auth)
  296. search = {"enterprise_id": enterprise_id}
  297. if institution_name:
  298. search["institution_name"] = institution_name
  299. offset = (page_no - 1) * page_size
  300. return await crud.page(
  301. offset=offset,
  302. limit=page_size,
  303. order_by=[{"id": "desc"}],
  304. search=search,
  305. out_schema=InstitutionListOutSchema,
  306. )
  307. @classmethod
  308. async def detailinfo_query_service(
  309. cls,
  310. auth: AuthSchema,
  311. institution_id: str,
  312. enterprise_id: str,
  313. ) -> dict | None:
  314. """
  315. 从支付宝查询费控制度详情,并补充本地规则和额度数据
  316. 调用: alipay.ebpp.invoice.institution.detailinfo.query
  317. 失败时降级到本地DB
  318. """
  319. result_dict = None
  320. try:
  321. model = AlipayEbppInvoiceInstitutionDetailinfoQueryModel()
  322. model.institution_id = institution_id
  323. model.enterprise_id = enterprise_id
  324. req = AlipayEbppInvoiceInstitutionDetailinfoQueryRequest()
  325. req.biz_model = model
  326. response = await asyncio.to_thread(cls._execute_alipay, req)
  327. if response:
  328. result = AlipayEbppInvoiceInstitutionDetailinfoQueryResponse()
  329. result.parse_response_content(response)
  330. if result.is_success():
  331. result_dict = {k: getattr(result, k) for k in (
  332. "adapter_type", "consult_mode", "currency", "effective",
  333. "effective_end_date", "effective_start_date", "expense_type",
  334. "institution_desc", "institution_id", "institution_name",
  335. "issue_rule_info_list", "multi_employee_share_mode",
  336. "outer_source_id", "owner_id_list", "owner_open_id_list",
  337. "owner_type", "scene_type", "standard_info_detail_list",
  338. "standard_info_list",
  339. ) if getattr(result, k, None) is not None}
  340. if not result_dict:
  341. log.warning("支付宝 detailinfo.query 失败,降级到本地DB")
  342. except Exception as e:
  343. log.warning(f"支付宝 detailinfo.query 异常: {e},降级到本地DB")
  344. # 降级:查本地DB
  345. if not result_dict:
  346. crud = InstitutionCRUD(auth)
  347. obj = await crud.get(institution_id=institution_id, enterprise_id=enterprise_id)
  348. if obj:
  349. result_dict = InstitutionListOutSchema.model_validate(obj).model_dump()
  350. if not result_dict:
  351. return None
  352. # 合并本地DB的自定义字段(支付宝不包含的字段)
  353. try:
  354. crud = InstitutionCRUD(auth)
  355. local_obj = await crud.get(institution_id=institution_id, enterprise_id=enterprise_id)
  356. if local_obj:
  357. local_dict = InstitutionListOutSchema.model_validate(local_obj).model_dump()
  358. for field in ("applicable_scope", "grant_mode", "period_type", "amount",
  359. "single_limit", "effective_time_type", "employee_ids",
  360. "scope_owner_id_list", "enterprise_id", "status",
  361. "created_time", "updated_time", "consult_mode"):
  362. if field in local_dict and local_dict[field] is not None:
  363. result_dict[field] = local_dict[field]
  364. except Exception as e:
  365. log.warning(f"合并本地DB字段失败: {e}")
  366. # 补充本地规则和额度
  367. from app.plugin.module_payment.expense.rule.model import ExpenseRuleModel
  368. from app.plugin.module_payment.expense.quota.model import QuotaModel
  369. from sqlalchemy import select
  370. # 查使用规则
  371. rule_stmt = select(ExpenseRuleModel).where(ExpenseRuleModel.institution_id == institution_id)
  372. rule_result = await auth.db.execute(rule_stmt)
  373. rules = rule_result.scalars().all()
  374. if rules:
  375. rule_list = []
  376. for r in rules:
  377. rule_item = {
  378. "rule_id": r.rule_id,
  379. "standard_name": r.standard_name,
  380. "standard_desc": r.standard_desc,
  381. }
  382. if hasattr(r, 'condition_info') and r.condition_info:
  383. rule_item["condition_info"] = r.condition_info
  384. if hasattr(r, 'single_limit') and r.single_limit:
  385. rule_item["single_limit"] = float(r.single_limit)
  386. rule_list.append(rule_item)
  387. result_dict["rule_list"] = rule_list
  388. # 查额度
  389. quota_stmt = select(QuotaModel).where(QuotaModel.institution_id == institution_id)
  390. quota_result = await auth.db.execute(quota_stmt)
  391. quotas = quota_result.scalars().all()
  392. if quotas:
  393. result_dict["quota_list"] = [
  394. {
  395. "quota_id": q.quota_id,
  396. "employee_id": q.employee_id or "",
  397. "out_biz_no": q.out_biz_no,
  398. "total_amount": float(q.total_amount) if q.total_amount else 0,
  399. "available_amount": float(q.available_amount) if q.available_amount else 0,
  400. "status": q.status,
  401. }
  402. for q in quotas
  403. ]
  404. return result_dict
  405. @classmethod
  406. async def list_service(
  407. cls,
  408. auth: AuthSchema,
  409. page_no: int = 1,
  410. page_size: int = 20,
  411. search: dict | None = None,
  412. ) -> dict:
  413. """
  414. 查询费控制度列表
  415. 优先调支付宝,失败降级到本地DB
  416. """
  417. enterprise_id = (search or {}).get("enterprise_id", "")
  418. institution_name = (search or {}).get("name") or (search or {}).get("institution_name")
  419. if enterprise_id:
  420. return await cls.pageinfo_query_service(
  421. auth=auth,
  422. enterprise_id=enterprise_id,
  423. page_no=page_no,
  424. page_size=page_size,
  425. institution_name=institution_name,
  426. )
  427. # 无 enterprise_id 时直接查本地
  428. crud = InstitutionCRUD(auth)
  429. offset = (page_no - 1) * page_size
  430. return await crud.page(
  431. offset=offset,
  432. limit=page_size,
  433. order_by=[{"id": "desc"}],
  434. search=search or {},
  435. out_schema=InstitutionListOutSchema,
  436. )
  437. @classmethod
  438. async def delete_institution_service(
  439. cls, auth: AuthSchema, data: AlipayEbppInvoiceInstitutionDeleteModel
  440. ) -> dict:
  441. """
  442. 删除费控制度
  443. 调用: alipay.ebpp.invoice.institution.delete
  444. 支付宝侧已删时忽略错误,始终清理本地关联表
  445. """
  446. institution_id = getattr(data, 'institution_id', None)
  447. # 调用支付宝删除(失败时仅告警,不影响本地清理)
  448. try:
  449. request = AlipayEbppInvoiceInstitutionDeleteRequest()
  450. request.biz_model = data
  451. response = await asyncio.to_thread(cls._execute_alipay, request)
  452. if response:
  453. result = AlipayEbppInvoiceInstitutionDeleteResponse()
  454. result.parse_response_content(response)
  455. if result.is_success():
  456. log.info(f"支付宝删除成功: institution_id={institution_id}")
  457. else:
  458. log.warning(f"支付宝删除失败(可能已删): {result.code} - {result.msg}")
  459. else:
  460. log.warning("支付宝删除无响应,继续清理本地")
  461. except Exception as e:
  462. log.warning(f"支付宝删除异常(忽略): {e}")
  463. # 清理本地关联表
  464. if institution_id:
  465. try:
  466. from app.plugin.module_payment.expense.rule.model import ExpenseRuleModel
  467. from app.plugin.module_payment.expense.quota.model import QuotaModel
  468. from app.plugin.module_payment.expense.institution.model import ExpenseInstitutionModel
  469. from sqlalchemy import delete as sa_delete
  470. # 删规则
  471. await auth.db.execute(sa_delete(ExpenseRuleModel).where(ExpenseRuleModel.institution_id == institution_id))
  472. # 删额度
  473. await auth.db.execute(sa_delete(QuotaModel).where(QuotaModel.institution_id == institution_id))
  474. # 删制度
  475. await auth.db.execute(sa_delete(ExpenseInstitutionModel).where(ExpenseInstitutionModel.institution_id == institution_id))
  476. await auth.db.flush()
  477. log.info(f"本地关联数据已清理: institution_id={institution_id}")
  478. except Exception as e:
  479. log.warning(f"本地清理失败: {e}")
  480. return {"institution_id": institution_id, "deleted": True}
  481. @classmethod
  482. async def modify_institution_service(
  483. cls, auth: AuthSchema, data: AlipayEbppInvoiceInstitutionModifyModel, raw_data: dict | None = None,
  484. scope_info: dict | None = None,
  485. ) -> dict:
  486. """
  487. 编辑费控制度
  488. 调用: alipay.ebpp.invoice.institution.modify
  489. 适用范围修改(scope_info)需单独调 scope.modify,与基础信息拆分两次请求
  490. 支付宝成功后同步更新本地DB:
  491. - 制度基本信息
  492. - 适用员工范围(scope)
  493. - 使用规则(standard_info_list → pay_expense_rule)
  494. - 额度(issuerule → pay_expense_quota)
  495. """
  496. if data.institution_id is None:
  497. raise CustomException(msg="编辑费控制度失败: 制度ID不能为空")
  498. institution_id = data.institution_id
  499. enterprise_id = getattr(data, 'enterprise_id', None) or (raw_data or {}).get("enterprise_id", "")
  500. raw_data = raw_data or {}
  501. # 第1步:修改支付宝制度基础信息(不含 scope)
  502. request = AlipayEbppInvoiceInstitutionModifyRequest()
  503. request.biz_model = data
  504. response = await asyncio.to_thread(cls._execute_alipay, request)
  505. if not response:
  506. raise CustomException(msg="编辑费控制度失败: 无响应")
  507. result = AlipayEbppInvoiceInstitutionModifyResponse()
  508. result.parse_response_content(response)
  509. if not result.is_success():
  510. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  511. raise CustomException(msg=f"编辑费控制度失败: {result.msg}")
  512. # 第1.5步:单独调用 scope.modify(不与基础修改在同一请求中)
  513. if scope_info:
  514. try:
  515. await InstitutionScopeService.scope_modify_service(
  516. auth=auth, institution_id=institution_id, data=scope_info
  517. )
  518. log.info(f"适用范围已单独同步: adapter_type={scope_info.get('adapter_type')}")
  519. except Exception as e:
  520. log.warning(f"适用范围同步失败(不影响基础修改,本地DB将更新为最新值): {e}")
  521. applicable_scope = raw_data.get("applicable_scope", "")
  522. # 第2步:同步更新本地数据库(scope 已在 Alipay modify 请求中通过 modify_scope_info 处理)
  523. try:
  524. crud = InstitutionCRUD(auth)
  525. update_data = {}
  526. if hasattr(data, 'institution_name') and data.institution_name:
  527. update_data['institution_name'] = data.institution_name
  528. if hasattr(data, 'institution_desc') and data.institution_desc:
  529. update_data['institution_desc'] = data.institution_desc
  530. if hasattr(data, 'effective') and data.effective is not None:
  531. update_data['effective'] = data.effective
  532. update_data['status'] = (
  533. InstitutionStatusEnum.INSTITUTION_EFFECTIVE.value
  534. if data.effective == "1"
  535. else InstitutionStatusEnum.INSTITUTION_INVALID.value
  536. )
  537. if hasattr(data, 'effective_start_date') and data.effective_start_date:
  538. val = data.effective_start_date
  539. update_data['effective_start_date'] = datetime.fromisoformat(val.replace(' ', 'T')) if isinstance(val, str) else val
  540. if hasattr(data, 'effective_end_date') and data.effective_end_date:
  541. val = data.effective_end_date
  542. update_data['effective_end_date'] = datetime.fromisoformat(val.replace(' ', 'T')) if isinstance(val, str) else val
  543. if applicable_scope:
  544. update_data['applicable_scope'] = applicable_scope
  545. # 同步额外配置字段
  546. for field in ("grant_mode", "period_type", "amount", "single_limit", "effective_time_type", "expense_type"):
  547. val = raw_data.get(field)
  548. if val is not None:
  549. update_data[field] = val
  550. if update_data:
  551. await crud.update_by_institution_id(institution_id, update_data)
  552. log.info(f"已更新本地制度: institution_id={institution_id}")
  553. # 同步标准规则(modify_standard_detail_info)
  554. std_detail = (raw_data or {}).get("modify_standard_detail_info") or {}
  555. if std_detail:
  556. from app.plugin.module_payment.expense.rule.model import ExpenseRuleModel
  557. from sqlalchemy import delete as sa_delete
  558. # 删除规则
  559. delete_ids = std_detail.get("delete_standard_id_list", [])
  560. if delete_ids:
  561. d_stmt = sa_delete(ExpenseRuleModel).where(
  562. ExpenseRuleModel.rule_id.in_(delete_ids)
  563. )
  564. await auth.db.execute(d_stmt)
  565. # 新增规则
  566. add_list = std_detail.get("add_standard_list", [])
  567. from sqlalchemy import insert as sa_insert
  568. for std in add_list:
  569. ins_data = {
  570. "out_biz_no": std.get("outer_source_id", f"std_{institution_id}"),
  571. "institution_id": institution_id,
  572. "rule_id": std.get("standard_id"),
  573. "standard_name": std.get("standard_name"),
  574. "standard_desc": std.get("standard_desc"),
  575. "expense_type_sub_category": std.get("expense_type_sub_category", "DEFAULT"),
  576. "enterprise_id": raw_data.get("enterprise_id", ""),
  577. "tenant_id": auth.user.tenant_id if auth.user else 1,
  578. }
  579. stmt = sa_insert(ExpenseRuleModel).values(**ins_data)
  580. await auth.db.execute(stmt)
  581. # 修改规则
  582. modify_list = std_detail.get("modify_standard_list", [])
  583. for std in modify_list:
  584. std_id = std.get("standard_id", "").strip('"')
  585. update_std = {}
  586. if std.get("standard_name"):
  587. update_std["standard_name"] = std["standard_name"]
  588. if std.get("standard_desc"):
  589. update_std["standard_desc"] = std["standard_desc"]
  590. if update_std and std_id:
  591. from sqlalchemy import update as sa_update
  592. u_stmt = sa_update(ExpenseRuleModel).where(
  593. ExpenseRuleModel.rule_id == std_id
  594. ).values(**update_std)
  595. await auth.db.execute(u_stmt)
  596. await auth.db.flush()
  597. log.info(f"已同步使用规则: institution_id={institution_id}")
  598. # 同步发放规则(modify_issue_rule_detail_info)
  599. issue_detail = (raw_data or {}).get("modify_issue_rule_detail_info") or {}
  600. if issue_detail:
  601. from app.plugin.module_payment.expense.quota.model import QuotaModel
  602. from sqlalchemy import delete as sa_delete
  603. # 删除发放规则
  604. delete_ids = issue_detail.get("delete_issue_rule_id_list", [])
  605. if delete_ids:
  606. d_stmt = sa_delete(QuotaModel).where(
  607. QuotaModel.quota_id.in_(delete_ids)
  608. )
  609. await auth.db.execute(d_stmt)
  610. # 新增发放规则
  611. add_list = issue_detail.get("add_issue_rule_list", [])
  612. for rule in add_list:
  613. amount = float(rule.get("issue_amount_value", 0))
  614. ins_data = {
  615. "employee_id": "",
  616. "institution_id": institution_id,
  617. "out_biz_no": rule.get("outer_source_id", f"issue_{institution_id}"),
  618. "quota_id": rule.get("issue_rule_id"),
  619. "total_amount": amount,
  620. "available_amount": amount,
  621. "status": "QUOTA_ACTIVE",
  622. "enterprise_id": raw_data.get("enterprise_id", ""),
  623. "tenant_id": auth.user.tenant_id if auth.user else 1,
  624. }
  625. stmt = sa_insert(QuotaModel).values(**ins_data)
  626. await auth.db.execute(stmt)
  627. # 修改发放规则
  628. modify_rule = issue_detail.get("modify_issue_rule_list") or {}
  629. if modify_rule.get("issue_rule_id"):
  630. q_id = modify_rule["issue_rule_id"].strip('"')
  631. update_q = {}
  632. if modify_rule.get("issue_amount_value"):
  633. update_q["total_amount"] = float(modify_rule["issue_amount_value"])
  634. update_q["available_amount"] = float(modify_rule["issue_amount_value"])
  635. if modify_rule.get("issue_rule_name"):
  636. update_q["standard_name"] = modify_rule["issue_rule_name"]
  637. if update_q and q_id:
  638. from sqlalchemy import update as sa_update
  639. u_stmt = sa_update(QuotaModel).where(
  640. QuotaModel.quota_id == q_id
  641. ).values(**update_q)
  642. await auth.db.execute(u_stmt)
  643. await auth.db.flush()
  644. log.info(f"已同步发放规则: institution_id={institution_id}")
  645. except Exception as e:
  646. log.warning(f"本地同步失败(不影响支付宝侧): {e}")
  647. return result
  648. class InstitutionScopeService:
  649. """费控制度成员范围服务层"""
  650. @classmethod
  651. def _execute_alipay(cls, request):
  652. """同步执行支付宝调用"""
  653. client = AlipayClient.get_client()
  654. return client.execute(request)
  655. @classmethod
  656. async def scope_modify_service(
  657. cls,
  658. auth: AuthSchema,
  659. institution_id: str,
  660. data: dict,
  661. ) -> dict:
  662. """
  663. 设置/修改制度成员范围
  664. 调用: alipay.ebpp.invoice.institution.scope.modify
  665. """
  666. try:
  667. from alipay.aop.api.request.AlipayEbppInvoiceInstitutionScopeModifyRequest import (
  668. AlipayEbppInvoiceInstitutionScopeModifyRequest,
  669. )
  670. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionScopeModifyModel import (
  671. AlipayEbppInvoiceInstitutionScopeModifyModel,
  672. )
  673. from alipay.aop.api.response.AlipayEbppInvoiceInstitutionScopeModifyResponse import (
  674. AlipayEbppInvoiceInstitutionScopeModifyResponse,
  675. )
  676. except ImportError:
  677. raise CustomException(msg="支付宝SDK未正确安装(alipay-ebpp-invoice-institution-scope-modify)")
  678. model = AlipayEbppInvoiceInstitutionScopeModifyModel()
  679. model.institution_id = institution_id
  680. model.enterprise_id = data.get("enterprise_id", "")
  681. model.adapter_type = data.get("adapter_type", "EMPLOYEE_ALL")
  682. if data.get("owner_type"):
  683. model.owner_type = data["owner_type"]
  684. if data.get("add_owner_id_list"):
  685. model.add_owner_id_list = data["add_owner_id_list"]
  686. if data.get("delete_owner_id_list"):
  687. model.delete_owner_id_list = data["delete_owner_id_list"]
  688. request = AlipayEbppInvoiceInstitutionScopeModifyRequest()
  689. request.biz_model = model
  690. response = await asyncio.to_thread(cls._execute_alipay, request)
  691. if not response:
  692. raise CustomException(msg="设置制度成员失败: 无响应")
  693. result = AlipayEbppInvoiceInstitutionScopeModifyResponse()
  694. result.parse_response_content(response)
  695. if not result.is_success():
  696. log.error(f"设置制度成员失败: {result.code} - {result.msg}")
  697. raise CustomException(msg=f"设置制度成员失败: {result.msg}")
  698. return {"result": True}
  699. @classmethod
  700. async def scopepageinfo_query_service(
  701. cls,
  702. auth: AuthSchema,
  703. institution_id: str,
  704. enterprise_id: str | None = None,
  705. page_num: int = 1,
  706. page_size: int = 20,
  707. owner_type: str | None = None,
  708. ) -> dict:
  709. """
  710. 查询制度成员范围
  711. 调用: alipay.ebpp.invoice.institution.scopepageinfo.query
  712. """
  713. try:
  714. from alipay.aop.api.request.AlipayEbppInvoiceInstitutionScopepageinfoQueryRequest import (
  715. AlipayEbppInvoiceInstitutionScopepageinfoQueryRequest,
  716. )
  717. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionScopepageinfoQueryModel import (
  718. AlipayEbppInvoiceInstitutionScopepageinfoQueryModel,
  719. )
  720. from alipay.aop.api.response.AlipayEbppInvoiceInstitutionScopepageinfoQueryResponse import (
  721. AlipayEbppInvoiceInstitutionScopepageinfoQueryResponse,
  722. )
  723. except ImportError:
  724. raise CustomException(msg="支付宝SDK未正确安装(alipay-ebpp-invoice-institution-scopepageinfo-query)")
  725. model = AlipayEbppInvoiceInstitutionScopepageinfoQueryModel()
  726. model.institution_id = institution_id
  727. model.page_num = page_num
  728. model.page_size = page_size
  729. if enterprise_id:
  730. model.enterprise_id = enterprise_id
  731. if owner_type:
  732. model.owner_type = owner_type
  733. request = AlipayEbppInvoiceInstitutionScopepageinfoQueryRequest()
  734. request.biz_model = model
  735. response = await asyncio.to_thread(cls._execute_alipay, request)
  736. if not response:
  737. raise CustomException(msg="查询制度成员失败: 无响应")
  738. result = AlipayEbppInvoiceInstitutionScopepageinfoQueryResponse()
  739. result.parse_response_content(response)
  740. if not result.is_success():
  741. log.error(f"查询制度成员失败: {result.code} - {result.msg}")
  742. raise CustomException(msg=f"查询制度成员失败: {result.msg}")
  743. return {
  744. "page_num": getattr(result, 'page_num', page_num) or page_num,
  745. "page_size": getattr(result, 'page_size', page_size) or page_size,
  746. "total_page_count": getattr(result, 'total_page_count', 0) or 0,
  747. "adapter_type": getattr(result, 'adapter_type', None),
  748. "owner_id_list": getattr(result, 'owner_id_list', []) or [],
  749. "owner_open_id_list": getattr(result, 'onwer_open_id_list', []) or [],
  750. "scope_info_list": [
  751. {
  752. "adapter_type": getattr(result, 'adapter_type', None),
  753. "owner_id_list": getattr(result, 'owner_id_list', []) or [],
  754. "owner_open_id_list": getattr(result, 'onwer_open_id_list', []) or [],
  755. }
  756. ] if getattr(result, 'adapter_type', None) else [],
  757. }
  758. class IssueruleService:
  759. """自动额度发放规则服务层"""
  760. ISSUE_TYPE_MAP = {
  761. "daily": "ISSUE_DAY",
  762. "weekly": "ISSUE_WEEK",
  763. "monthly": "ISSUE_MONTH",
  764. "quarterly": "ISSUE_QUARTER",
  765. "yearly": "ISSUE_YEAR",
  766. }
  767. @classmethod
  768. def _execute_alipay(cls, request):
  769. client = AlipayClient.get_client()
  770. return client.execute(request)
  771. @classmethod
  772. async def create_issuerule_service(
  773. cls,
  774. auth: AuthSchema,
  775. institution_id: str,
  776. enterprise_id: str,
  777. quota_type: str,
  778. issue_type: str,
  779. issue_amount_value: str,
  780. outer_source_id: str | None = None,
  781. issue_rule_name: str | None = None,
  782. effective_period: str | None = None,
  783. invalid_mode: int | None = None,
  784. share_mode: int | None = None,
  785. ) -> dict:
  786. """
  787. 创建自动额度发放规则
  788. 调用: alipay.ebpp.invoice.issuerule.create
  789. """
  790. try:
  791. from alipay.aop.api.request.AlipayEbppInvoiceIssueruleCreateRequest import (
  792. AlipayEbppInvoiceIssueruleCreateRequest,
  793. )
  794. from alipay.aop.api.domain.AlipayEbppInvoiceIssueruleCreateModel import (
  795. AlipayEbppInvoiceIssueruleCreateModel,
  796. )
  797. from alipay.aop.api.response.AlipayEbppInvoiceIssueruleCreateResponse import (
  798. AlipayEbppInvoiceIssueruleCreateResponse,
  799. )
  800. except ImportError:
  801. raise CustomException(msg="支付宝SDK未正确安装(alipay-ebpp-invoice-issuerule-create)")
  802. # 参数约束校验
  803. if quota_type == "CAP" and invalid_mode is not None and invalid_mode != 1:
  804. raise CustomException(msg="余额类型(CP)的发放规则必须为可累计(invalid_mode=1)")
  805. if quota_type == "COUNT" and share_mode is not None and share_mode != 0:
  806. raise CustomException(msg="次卡类型(COUNT)的发放规则不可转赠(share_mode=0)")
  807. model = AlipayEbppInvoiceIssueruleCreateModel()
  808. model.target_type = "INSTITUTION"
  809. model.target_id = institution_id
  810. model.quota_type = quota_type
  811. model.issue_type = issue_type
  812. model.issue_amount_value = issue_amount_value
  813. model.enterprise_id = enterprise_id
  814. if outer_source_id:
  815. model.outer_source_id = outer_source_id
  816. if issue_rule_name:
  817. model.issue_rule_name = issue_rule_name
  818. if effective_period:
  819. model.effective_period = effective_period
  820. if invalid_mode is not None:
  821. model.invalid_mode = invalid_mode
  822. if share_mode is not None:
  823. model.share_mode = share_mode
  824. request = AlipayEbppInvoiceIssueruleCreateRequest()
  825. request.biz_model = model
  826. response = await asyncio.to_thread(cls._execute_alipay, request)
  827. if not response:
  828. raise CustomException(msg="创建发放规则失败: 无响应")
  829. result = AlipayEbppInvoiceIssueruleCreateResponse()
  830. result.parse_response_content(response)
  831. if not result.is_success():
  832. log.error(f"创建发放规则失败: {result.code} - {result.msg}")
  833. raise CustomException(msg=f"创建发放规则失败: {result.msg}")
  834. return {
  835. "issue_rule_id": getattr(result, 'issue_rule_id', None),
  836. }
  837. @classmethod
  838. async def modify_issuerule_service(
  839. cls,
  840. auth: AuthSchema,
  841. institution_id: str,
  842. issue_rule_id: str,
  843. enterprise_id: str,
  844. quota_type: str | None = None,
  845. issue_type: str | None = None,
  846. issue_amount_value: str | None = None,
  847. issue_rule_name: str | None = None,
  848. effective: str | None = None,
  849. effective_period: str | None = None,
  850. invalid_mode: int | None = None,
  851. share_mode: int | None = None,
  852. ) -> dict:
  853. """
  854. 编辑自动额度发放规则
  855. 调用: alipay.ebpp.invoice.issuerule.modify
  856. """
  857. try:
  858. from alipay.aop.api.request.AlipayEbppInvoiceIssueruleModifyRequest import (
  859. AlipayEbppInvoiceIssueruleModifyRequest,
  860. )
  861. from alipay.aop.api.domain.AlipayEbppInvoiceIssueruleModifyModel import (
  862. AlipayEbppInvoiceIssueruleModifyModel,
  863. )
  864. from alipay.aop.api.response.AlipayEbppInvoiceIssueruleModifyResponse import (
  865. AlipayEbppInvoiceIssueruleModifyResponse,
  866. )
  867. except ImportError:
  868. raise CustomException(msg="支付宝SDK未正确安装(alipay-ebpp-invoice-issuerule-modify)")
  869. model = AlipayEbppInvoiceIssueruleModifyModel()
  870. model.target_type = "INSTITUTION"
  871. model.target_id = institution_id
  872. model.issue_rule_id = issue_rule_id
  873. model.action = "MODIFY_BASIC_INFO"
  874. model.enterprise_id = enterprise_id
  875. if issue_rule_name:
  876. model.issue_rule_name = issue_rule_name
  877. if quota_type:
  878. model.quota_type = quota_type
  879. if issue_type:
  880. model.issue_type = issue_type
  881. if issue_amount_value:
  882. model.issue_amount_value = issue_amount_value
  883. if effective is not None:
  884. model.effective = effective
  885. if effective_period:
  886. model.effective_period = effective_period
  887. if invalid_mode is not None:
  888. model.invalid_mode = invalid_mode
  889. if share_mode is not None:
  890. model.share_mode = share_mode
  891. request = AlipayEbppInvoiceIssueruleModifyRequest()
  892. request.biz_model = model
  893. response = await asyncio.to_thread(cls._execute_alipay, request)
  894. if not response:
  895. raise CustomException(msg="编辑发放规则失败: 无响应")
  896. result = AlipayEbppInvoiceIssueruleModifyResponse()
  897. result.parse_response_content(response)
  898. if not result.is_success():
  899. log.error(f"编辑发放规则失败: {result.code} - {result.msg}")
  900. raise CustomException(msg=f"编辑发放规则失败: {result.msg}")
  901. return {"result": True}
  902. @classmethod
  903. async def delete_issuerule_service(
  904. cls,
  905. auth: AuthSchema,
  906. institution_id: str,
  907. issue_rule_id_list: list[str],
  908. enterprise_id: str,
  909. ) -> dict:
  910. """
  911. 删除自动额度发放规则
  912. 调用: alipay.ebpp.invoice.issuerule.delete
  913. """
  914. try:
  915. from alipay.aop.api.request.AlipayEbppInvoiceIssueruleDeleteRequest import (
  916. AlipayEbppInvoiceIssueruleDeleteRequest,
  917. )
  918. from alipay.aop.api.domain.AlipayEbppInvoiceIssueruleDeleteModel import (
  919. AlipayEbppInvoiceIssueruleDeleteModel,
  920. )
  921. from alipay.aop.api.response.AlipayEbppInvoiceIssueruleDeleteResponse import (
  922. AlipayEbppInvoiceIssueruleDeleteResponse,
  923. )
  924. except ImportError:
  925. raise CustomException(msg="支付宝SDK未正确安装(alipay-ebpp-invoice-issuerule-delete)")
  926. model = AlipayEbppInvoiceIssueruleDeleteModel()
  927. model.target_type = "INSTITUTION"
  928. model.target_id = institution_id
  929. model.issue_rule_id_list = issue_rule_id_list
  930. model.enterprise_id = enterprise_id
  931. request = AlipayEbppInvoiceIssueruleDeleteRequest()
  932. request.biz_model = model
  933. response = await asyncio.to_thread(cls._execute_alipay, request)
  934. if not response:
  935. raise CustomException(msg="删除发放规则失败: 无响应")
  936. result = AlipayEbppInvoiceIssueruleDeleteResponse()
  937. result.parse_response_content(response)
  938. if not result.is_success():
  939. log.error(f"删除发放规则失败: {result.code} - {result.msg}")
  940. raise CustomException(msg=f"删除发放规则失败: {result.msg}")
  941. return {"result": True}