service.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989
  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步:保存发放规则到本地(不写入pay_expense_quota)
  223. # 去除假额度写入:额度由外部消费同步时通过
  224. # alipay.ebpp.invoice.expensecomsue.outsource.notify 写入真实数据
  225. return {
  226. "institution_id": institution_id,
  227. "scope_modified": scope_modified,
  228. "issue_rule_id": issue_rule_id,
  229. }
  230. @classmethod
  231. async def pageinfo_query_service(
  232. cls,
  233. auth: AuthSchema,
  234. enterprise_id: str,
  235. page_no: int = 1,
  236. page_size: int = 20,
  237. institution_name: str | None = None,
  238. ) -> dict:
  239. """
  240. 从支付宝查询费控制度列表
  241. 调用: alipay.ebpp.invoice.institution.pageinfo.query
  242. 失败时降级到本地DB
  243. """
  244. try:
  245. model = AlipayEbppInvoiceInstitutionPageinfoQueryModel()
  246. model.enterprise_id = enterprise_id
  247. model.page_num = page_no
  248. model.page_size = page_size
  249. if institution_name:
  250. model.institution_name = institution_name
  251. req = AlipayEbppInvoiceInstitutionPageinfoQueryRequest()
  252. req.biz_model = model
  253. response = await asyncio.to_thread(cls._execute_alipay, req)
  254. if response:
  255. result = AlipayEbppInvoiceInstitutionPageinfoQueryResponse()
  256. result.parse_response_content(response)
  257. if result.is_success():
  258. return {
  259. "page_no": getattr(result, 'page_num', page_no) or page_no,
  260. "page_size": getattr(result, 'page_size', page_size) or page_size,
  261. "total": getattr(result, 'total_page_count', 0) or 0,
  262. "list": getattr(result, 'institution_list', []) or [],
  263. }
  264. log.warning("支付宝 pageinfo.query 失败,降级到本地DB")
  265. except Exception as e:
  266. log.warning(f"支付宝 pageinfo.query 异常: {e},降级到本地DB")
  267. # 降级:查本地DB
  268. crud = InstitutionCRUD(auth)
  269. search = {"enterprise_id": enterprise_id}
  270. if institution_name:
  271. search["institution_name"] = institution_name
  272. offset = (page_no - 1) * page_size
  273. return await crud.page(
  274. offset=offset,
  275. limit=page_size,
  276. order_by=[{"id": "desc"}],
  277. search=search,
  278. out_schema=InstitutionListOutSchema,
  279. )
  280. @classmethod
  281. async def detailinfo_query_service(
  282. cls,
  283. auth: AuthSchema,
  284. institution_id: str,
  285. enterprise_id: str,
  286. ) -> dict | None:
  287. """
  288. 从支付宝查询费控制度详情,并补充本地规则和额度数据
  289. 调用: alipay.ebpp.invoice.institution.detailinfo.query
  290. 失败时降级到本地DB
  291. """
  292. result_dict = None
  293. try:
  294. model = AlipayEbppInvoiceInstitutionDetailinfoQueryModel()
  295. model.institution_id = institution_id
  296. model.enterprise_id = enterprise_id
  297. req = AlipayEbppInvoiceInstitutionDetailinfoQueryRequest()
  298. req.biz_model = model
  299. response = await asyncio.to_thread(cls._execute_alipay, req)
  300. if response:
  301. result = AlipayEbppInvoiceInstitutionDetailinfoQueryResponse()
  302. result.parse_response_content(response)
  303. if result.is_success():
  304. result_dict = {k: getattr(result, k) for k in (
  305. "adapter_type", "consult_mode", "currency", "effective",
  306. "effective_end_date", "effective_start_date", "expense_type",
  307. "institution_desc", "institution_id", "institution_name",
  308. "issue_rule_info_list", "multi_employee_share_mode",
  309. "outer_source_id", "owner_id_list", "owner_open_id_list",
  310. "owner_type", "scene_type", "standard_info_detail_list",
  311. "standard_info_list",
  312. ) if getattr(result, k, None) is not None}
  313. if not result_dict:
  314. log.warning("支付宝 detailinfo.query 失败,降级到本地DB")
  315. except Exception as e:
  316. log.warning(f"支付宝 detailinfo.query 异常: {e},降级到本地DB")
  317. # 降级:查本地DB
  318. if not result_dict:
  319. crud = InstitutionCRUD(auth)
  320. obj = await crud.get(institution_id=institution_id, enterprise_id=enterprise_id)
  321. if obj:
  322. result_dict = InstitutionListOutSchema.model_validate(obj).model_dump()
  323. if not result_dict:
  324. return None
  325. # 合并本地DB的自定义字段(支付宝不包含的字段)
  326. try:
  327. crud = InstitutionCRUD(auth)
  328. local_obj = await crud.get(institution_id=institution_id, enterprise_id=enterprise_id)
  329. if local_obj:
  330. local_dict = InstitutionListOutSchema.model_validate(local_obj).model_dump()
  331. for field in ("applicable_scope", "grant_mode", "period_type", "amount",
  332. "single_limit", "effective_time_type", "employee_ids",
  333. "scope_owner_id_list", "enterprise_id", "status",
  334. "created_time", "updated_time", "consult_mode"):
  335. if field in local_dict and local_dict[field] is not None:
  336. result_dict[field] = local_dict[field]
  337. except Exception as e:
  338. log.warning(f"合并本地DB字段失败: {e}")
  339. # 补充本地规则和额度
  340. from app.plugin.module_payment.expense.rule.model import ExpenseRuleModel
  341. from app.plugin.module_payment.expense.quota.model import QuotaModel
  342. from sqlalchemy import select
  343. # 查使用规则
  344. rule_stmt = select(ExpenseRuleModel).where(ExpenseRuleModel.institution_id == institution_id)
  345. rule_result = await auth.db.execute(rule_stmt)
  346. rules = rule_result.scalars().all()
  347. if rules:
  348. rule_list = []
  349. for r in rules:
  350. rule_item = {
  351. "rule_id": r.rule_id,
  352. "standard_name": r.standard_name,
  353. "standard_desc": r.standard_desc,
  354. }
  355. if hasattr(r, 'condition_info') and r.condition_info:
  356. rule_item["condition_info"] = r.condition_info
  357. if hasattr(r, 'single_limit') and r.single_limit:
  358. rule_item["single_limit"] = float(r.single_limit)
  359. rule_list.append(rule_item)
  360. result_dict["rule_list"] = rule_list
  361. # 查额度
  362. quota_stmt = select(QuotaModel).where(QuotaModel.institution_id == institution_id).limit(1000)
  363. quota_result = await auth.db.execute(quota_stmt)
  364. quotas = quota_result.scalars().all()
  365. if quotas:
  366. result_dict["quota_list"] = [
  367. {
  368. "quota_id": q.quota_id,
  369. "employee_id": q.employee_id or "",
  370. "out_biz_no": q.out_biz_no,
  371. "total_amount": float(q.total_amount) if q.total_amount else 0,
  372. "available_amount": float(q.available_amount) if q.available_amount else 0,
  373. "status": q.status,
  374. }
  375. for q in quotas
  376. ]
  377. return result_dict
  378. @classmethod
  379. async def list_service(
  380. cls,
  381. auth: AuthSchema,
  382. page_no: int = 1,
  383. page_size: int = 20,
  384. search: dict | None = None,
  385. ) -> dict:
  386. """
  387. 查询费控制度列表
  388. 优先调支付宝,失败降级到本地DB
  389. """
  390. enterprise_id = (search or {}).get("enterprise_id", "")
  391. institution_name = (search or {}).get("name") or (search or {}).get("institution_name")
  392. if enterprise_id:
  393. return await cls.pageinfo_query_service(
  394. auth=auth,
  395. enterprise_id=enterprise_id,
  396. page_no=page_no,
  397. page_size=page_size,
  398. institution_name=institution_name,
  399. )
  400. # 无 enterprise_id 时直接查本地
  401. crud = InstitutionCRUD(auth)
  402. offset = (page_no - 1) * page_size
  403. return await crud.page(
  404. offset=offset,
  405. limit=page_size,
  406. order_by=[{"id": "desc"}],
  407. search=search or {},
  408. out_schema=InstitutionListOutSchema,
  409. )
  410. @classmethod
  411. async def delete_institution_service(
  412. cls, auth: AuthSchema, data: AlipayEbppInvoiceInstitutionDeleteModel
  413. ) -> dict:
  414. """
  415. 删除费控制度
  416. 调用: alipay.ebpp.invoice.institution.delete
  417. 支付宝侧已删时忽略错误,始终清理本地关联表
  418. """
  419. institution_id = getattr(data, 'institution_id', None)
  420. # 调用支付宝删除(失败时仅告警,不影响本地清理)
  421. try:
  422. request = AlipayEbppInvoiceInstitutionDeleteRequest()
  423. request.biz_model = data
  424. response = await asyncio.to_thread(cls._execute_alipay, request)
  425. if response:
  426. result = AlipayEbppInvoiceInstitutionDeleteResponse()
  427. result.parse_response_content(response)
  428. if result.is_success():
  429. log.info(f"支付宝删除成功: institution_id={institution_id}")
  430. else:
  431. log.warning(f"支付宝删除失败(可能已删): {result.code} - {result.msg}")
  432. else:
  433. log.warning("支付宝删除无响应,继续清理本地")
  434. except Exception as e:
  435. log.warning(f"支付宝删除异常(忽略): {e}")
  436. # 清理本地关联表
  437. if institution_id:
  438. try:
  439. from app.plugin.module_payment.expense.rule.model import ExpenseRuleModel
  440. from app.plugin.module_payment.expense.quota.model import QuotaModel
  441. from app.plugin.module_payment.expense.institution.model import ExpenseInstitutionModel
  442. from sqlalchemy import delete as sa_delete
  443. # 删规则
  444. await auth.db.execute(sa_delete(ExpenseRuleModel).where(ExpenseRuleModel.institution_id == institution_id))
  445. # 删额度
  446. await auth.db.execute(sa_delete(QuotaModel).where(QuotaModel.institution_id == institution_id))
  447. # 删制度
  448. await auth.db.execute(sa_delete(ExpenseInstitutionModel).where(ExpenseInstitutionModel.institution_id == institution_id))
  449. await auth.db.flush()
  450. log.info(f"本地关联数据已清理: institution_id={institution_id}")
  451. except Exception as e:
  452. log.warning(f"本地清理失败: {e}")
  453. return {"institution_id": institution_id, "deleted": True}
  454. @classmethod
  455. async def modify_institution_service(
  456. cls, auth: AuthSchema, data: AlipayEbppInvoiceInstitutionModifyModel, raw_data: dict | None = None,
  457. scope_info: dict | None = None,
  458. ) -> dict:
  459. """
  460. 编辑费控制度
  461. 调用: alipay.ebpp.invoice.institution.modify
  462. 适用范围修改(scope_info)需单独调 scope.modify,与基础信息拆分两次请求
  463. 支付宝成功后同步更新本地DB:
  464. - 制度基本信息
  465. - 适用员工范围(scope)
  466. - 使用规则(standard_info_list → pay_expense_rule)
  467. - 额度(issuerule → pay_expense_quota)
  468. """
  469. if data.institution_id is None:
  470. raise CustomException(msg="编辑费控制度失败: 制度ID不能为空")
  471. institution_id = data.institution_id
  472. enterprise_id = getattr(data, 'enterprise_id', None) or (raw_data or {}).get("enterprise_id", "")
  473. raw_data = raw_data or {}
  474. # 第1步:修改支付宝制度基础信息(不含 scope)
  475. request = AlipayEbppInvoiceInstitutionModifyRequest()
  476. request.biz_model = data
  477. response = await asyncio.to_thread(cls._execute_alipay, request)
  478. if not response:
  479. raise CustomException(msg="编辑费控制度失败: 无响应")
  480. result = AlipayEbppInvoiceInstitutionModifyResponse()
  481. result.parse_response_content(response)
  482. if not result.is_success():
  483. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  484. raise CustomException(msg=f"编辑费控制度失败: {result.msg}")
  485. # 第1.5步:单独调用 scope.modify(不与基础修改在同一请求中)
  486. if scope_info:
  487. try:
  488. await InstitutionScopeService.scope_modify_service(
  489. auth=auth, institution_id=institution_id, data=scope_info
  490. )
  491. log.info(f"适用范围已单独同步: adapter_type={scope_info.get('adapter_type')}")
  492. except Exception as e:
  493. log.warning(f"适用范围同步失败(不影响基础修改,本地DB将更新为最新值): {e}")
  494. applicable_scope = raw_data.get("applicable_scope", "")
  495. # 第2步:同步更新本地数据库(scope 已在 Alipay modify 请求中通过 modify_scope_info 处理)
  496. try:
  497. crud = InstitutionCRUD(auth)
  498. update_data = {}
  499. if hasattr(data, 'institution_name') and data.institution_name:
  500. update_data['institution_name'] = data.institution_name
  501. if hasattr(data, 'institution_desc') and data.institution_desc:
  502. update_data['institution_desc'] = data.institution_desc
  503. if hasattr(data, 'effective') and data.effective is not None:
  504. update_data['effective'] = data.effective
  505. update_data['status'] = (
  506. InstitutionStatusEnum.INSTITUTION_EFFECTIVE.value
  507. if data.effective == "1"
  508. else InstitutionStatusEnum.INSTITUTION_INVALID.value
  509. )
  510. if hasattr(data, 'effective_start_date') and data.effective_start_date:
  511. val = data.effective_start_date
  512. update_data['effective_start_date'] = datetime.fromisoformat(val.replace(' ', 'T')) if isinstance(val, str) else val
  513. if hasattr(data, 'effective_end_date') and data.effective_end_date:
  514. val = data.effective_end_date
  515. update_data['effective_end_date'] = datetime.fromisoformat(val.replace(' ', 'T')) if isinstance(val, str) else val
  516. if applicable_scope:
  517. update_data['applicable_scope'] = applicable_scope
  518. # 同步额外配置字段
  519. for field in ("grant_mode", "period_type", "amount", "single_limit", "effective_time_type", "expense_type"):
  520. val = raw_data.get(field)
  521. if val is not None:
  522. update_data[field] = val
  523. if update_data:
  524. await crud.update_by_institution_id(institution_id, update_data)
  525. log.info(f"已更新本地制度: institution_id={institution_id}")
  526. # 同步标准规则(modify_standard_detail_info)
  527. std_detail = (raw_data or {}).get("modify_standard_detail_info") or {}
  528. if std_detail:
  529. from app.plugin.module_payment.expense.rule.model import ExpenseRuleModel
  530. from sqlalchemy import delete as sa_delete
  531. # 删除规则
  532. delete_ids = std_detail.get("delete_standard_id_list", [])
  533. if delete_ids:
  534. d_stmt = sa_delete(ExpenseRuleModel).where(
  535. ExpenseRuleModel.rule_id.in_(delete_ids)
  536. )
  537. await auth.db.execute(d_stmt)
  538. # 新增规则
  539. add_list = std_detail.get("add_standard_list", [])
  540. from sqlalchemy import insert as sa_insert
  541. for std in add_list:
  542. ins_data = {
  543. "out_biz_no": std.get("outer_source_id", f"std_{institution_id}"),
  544. "institution_id": institution_id,
  545. "rule_id": std.get("standard_id"),
  546. "standard_name": std.get("standard_name"),
  547. "standard_desc": std.get("standard_desc"),
  548. "expense_type_sub_category": std.get("expense_type_sub_category", "DEFAULT"),
  549. "enterprise_id": raw_data.get("enterprise_id", ""),
  550. "tenant_id": auth.user.tenant_id if auth.user else 1,
  551. }
  552. stmt = sa_insert(ExpenseRuleModel).values(**ins_data)
  553. await auth.db.execute(stmt)
  554. # 修改规则
  555. modify_list = std_detail.get("modify_standard_list", [])
  556. for std in modify_list:
  557. std_id = std.get("standard_id", "").strip('"')
  558. update_std = {}
  559. if std.get("standard_name"):
  560. update_std["standard_name"] = std["standard_name"]
  561. if std.get("standard_desc"):
  562. update_std["standard_desc"] = std["standard_desc"]
  563. if update_std and std_id:
  564. from sqlalchemy import update as sa_update
  565. u_stmt = sa_update(ExpenseRuleModel).where(
  566. ExpenseRuleModel.rule_id == std_id
  567. ).values(**update_std)
  568. await auth.db.execute(u_stmt)
  569. await auth.db.flush()
  570. log.info(f"已同步使用规则: institution_id={institution_id}")
  571. # 同步发放规则(modify_issue_rule_detail_info)已去除
  572. # 发放规则对应的额度数据由外部消费同步时通过
  573. # alipay.ebpp.invoice.expensecomsue.outsource.notify 写入真实数据
  574. except Exception as e:
  575. log.warning(f"本地同步失败(不影响支付宝侧): {e}")
  576. return result
  577. class InstitutionScopeService:
  578. """费控制度成员范围服务层"""
  579. @classmethod
  580. def _execute_alipay(cls, request):
  581. """同步执行支付宝调用"""
  582. client = AlipayClient.get_client()
  583. return client.execute(request)
  584. @classmethod
  585. async def scope_modify_service(
  586. cls,
  587. auth: AuthSchema,
  588. institution_id: str,
  589. data: dict,
  590. ) -> dict:
  591. """
  592. 设置/修改制度成员范围
  593. 调用: alipay.ebpp.invoice.institution.scope.modify
  594. """
  595. try:
  596. from alipay.aop.api.request.AlipayEbppInvoiceInstitutionScopeModifyRequest import (
  597. AlipayEbppInvoiceInstitutionScopeModifyRequest,
  598. )
  599. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionScopeModifyModel import (
  600. AlipayEbppInvoiceInstitutionScopeModifyModel,
  601. )
  602. from alipay.aop.api.response.AlipayEbppInvoiceInstitutionScopeModifyResponse import (
  603. AlipayEbppInvoiceInstitutionScopeModifyResponse,
  604. )
  605. except ImportError:
  606. raise CustomException(msg="支付宝SDK未正确安装(alipay-ebpp-invoice-institution-scope-modify)")
  607. model = AlipayEbppInvoiceInstitutionScopeModifyModel()
  608. model.institution_id = institution_id
  609. model.enterprise_id = data.get("enterprise_id", "")
  610. model.adapter_type = data.get("adapter_type", "EMPLOYEE_ALL")
  611. if data.get("owner_type"):
  612. model.owner_type = data["owner_type"]
  613. if data.get("add_owner_id_list"):
  614. model.add_owner_id_list = data["add_owner_id_list"]
  615. if data.get("delete_owner_id_list"):
  616. model.delete_owner_id_list = data["delete_owner_id_list"]
  617. request = AlipayEbppInvoiceInstitutionScopeModifyRequest()
  618. request.biz_model = model
  619. response = await asyncio.to_thread(cls._execute_alipay, request)
  620. if not response:
  621. raise CustomException(msg="设置制度成员失败: 无响应")
  622. result = AlipayEbppInvoiceInstitutionScopeModifyResponse()
  623. result.parse_response_content(response)
  624. if not result.is_success():
  625. log.error(f"设置制度成员失败: {result.code} - {result.msg}")
  626. raise CustomException(msg=f"设置制度成员失败: {result.msg}")
  627. return {"result": True}
  628. @classmethod
  629. async def scopepageinfo_query_service(
  630. cls,
  631. auth: AuthSchema,
  632. institution_id: str,
  633. enterprise_id: str | None = None,
  634. page_num: int = 1,
  635. page_size: int = 20,
  636. owner_type: str | None = None,
  637. ) -> dict:
  638. """
  639. 查询制度成员范围
  640. 调用: alipay.ebpp.invoice.institution.scopepageinfo.query
  641. """
  642. try:
  643. from alipay.aop.api.request.AlipayEbppInvoiceInstitutionScopepageinfoQueryRequest import (
  644. AlipayEbppInvoiceInstitutionScopepageinfoQueryRequest,
  645. )
  646. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionScopepageinfoQueryModel import (
  647. AlipayEbppInvoiceInstitutionScopepageinfoQueryModel,
  648. )
  649. from alipay.aop.api.response.AlipayEbppInvoiceInstitutionScopepageinfoQueryResponse import (
  650. AlipayEbppInvoiceInstitutionScopepageinfoQueryResponse,
  651. )
  652. except ImportError:
  653. raise CustomException(msg="支付宝SDK未正确安装(alipay-ebpp-invoice-institution-scopepageinfo-query)")
  654. model = AlipayEbppInvoiceInstitutionScopepageinfoQueryModel()
  655. model.institution_id = institution_id
  656. model.page_num = page_num
  657. model.page_size = page_size
  658. if enterprise_id:
  659. model.enterprise_id = enterprise_id
  660. if owner_type:
  661. model.owner_type = owner_type
  662. request = AlipayEbppInvoiceInstitutionScopepageinfoQueryRequest()
  663. request.biz_model = model
  664. response = await asyncio.to_thread(cls._execute_alipay, request)
  665. if not response:
  666. raise CustomException(msg="查询制度成员失败: 无响应")
  667. result = AlipayEbppInvoiceInstitutionScopepageinfoQueryResponse()
  668. result.parse_response_content(response)
  669. if not result.is_success():
  670. log.error(f"查询制度成员失败: {result.code} - {result.msg}")
  671. raise CustomException(msg=f"查询制度成员失败: {result.msg}")
  672. return {
  673. "page_num": getattr(result, 'page_num', page_num) or page_num,
  674. "page_size": getattr(result, 'page_size', page_size) or page_size,
  675. "total_page_count": getattr(result, 'total_page_count', 0) or 0,
  676. "adapter_type": getattr(result, 'adapter_type', None),
  677. "owner_id_list": getattr(result, 'owner_id_list', []) or [],
  678. "owner_open_id_list": getattr(result, 'onwer_open_id_list', []) or [],
  679. "scope_info_list": [
  680. {
  681. "adapter_type": getattr(result, 'adapter_type', None),
  682. "owner_id_list": getattr(result, 'owner_id_list', []) or [],
  683. "owner_open_id_list": getattr(result, 'onwer_open_id_list', []) or [],
  684. }
  685. ] if getattr(result, 'adapter_type', None) else [],
  686. }
  687. class IssueruleService:
  688. """自动额度发放规则服务层"""
  689. ISSUE_TYPE_MAP = {
  690. "daily": "ISSUE_DAY",
  691. "weekly": "ISSUE_WEEK",
  692. "monthly": "ISSUE_MONTH",
  693. "quarterly": "ISSUE_QUARTER",
  694. "yearly": "ISSUE_YEAR",
  695. }
  696. @classmethod
  697. def _execute_alipay(cls, request):
  698. client = AlipayClient.get_client()
  699. return client.execute(request)
  700. @classmethod
  701. async def create_issuerule_service(
  702. cls,
  703. auth: AuthSchema,
  704. institution_id: str,
  705. enterprise_id: str,
  706. quota_type: str,
  707. issue_type: str,
  708. issue_amount_value: str,
  709. outer_source_id: str | None = None,
  710. issue_rule_name: str | None = None,
  711. effective_period: str | None = None,
  712. invalid_mode: int | None = None,
  713. share_mode: int | None = None,
  714. ) -> dict:
  715. """
  716. 创建自动额度发放规则
  717. 调用: alipay.ebpp.invoice.issuerule.create
  718. """
  719. try:
  720. from alipay.aop.api.request.AlipayEbppInvoiceIssueruleCreateRequest import (
  721. AlipayEbppInvoiceIssueruleCreateRequest,
  722. )
  723. from alipay.aop.api.domain.AlipayEbppInvoiceIssueruleCreateModel import (
  724. AlipayEbppInvoiceIssueruleCreateModel,
  725. )
  726. from alipay.aop.api.response.AlipayEbppInvoiceIssueruleCreateResponse import (
  727. AlipayEbppInvoiceIssueruleCreateResponse,
  728. )
  729. except ImportError:
  730. raise CustomException(msg="支付宝SDK未正确安装(alipay-ebpp-invoice-issuerule-create)")
  731. # 参数约束校验
  732. if quota_type == "CAP" and invalid_mode is not None and invalid_mode != 1:
  733. raise CustomException(msg="余额类型(CP)的发放规则必须为可累计(invalid_mode=1)")
  734. if quota_type == "COUNT" and share_mode is not None and share_mode != 0:
  735. raise CustomException(msg="次卡类型(COUNT)的发放规则不可转赠(share_mode=0)")
  736. model = AlipayEbppInvoiceIssueruleCreateModel()
  737. model.target_type = "INSTITUTION"
  738. model.target_id = institution_id
  739. model.quota_type = quota_type
  740. model.issue_type = issue_type
  741. model.issue_amount_value = issue_amount_value
  742. model.enterprise_id = enterprise_id
  743. if outer_source_id:
  744. model.outer_source_id = outer_source_id
  745. if issue_rule_name:
  746. model.issue_rule_name = issue_rule_name
  747. if effective_period:
  748. model.effective_period = effective_period
  749. if invalid_mode is not None:
  750. model.invalid_mode = invalid_mode
  751. if share_mode is not None:
  752. model.share_mode = share_mode
  753. request = AlipayEbppInvoiceIssueruleCreateRequest()
  754. request.biz_model = model
  755. response = await asyncio.to_thread(cls._execute_alipay, request)
  756. if not response:
  757. raise CustomException(msg="创建发放规则失败: 无响应")
  758. result = AlipayEbppInvoiceIssueruleCreateResponse()
  759. result.parse_response_content(response)
  760. if not result.is_success():
  761. log.error(f"创建发放规则失败: {result.code} - {result.msg}")
  762. raise CustomException(msg=f"创建发放规则失败: {result.msg}")
  763. return {
  764. "issue_rule_id": getattr(result, 'issue_rule_id', None),
  765. }
  766. @classmethod
  767. async def modify_issuerule_service(
  768. cls,
  769. auth: AuthSchema,
  770. institution_id: str,
  771. issue_rule_id: str,
  772. enterprise_id: str,
  773. quota_type: str | None = None,
  774. issue_type: str | None = None,
  775. issue_amount_value: str | None = None,
  776. issue_rule_name: str | None = None,
  777. effective: str | None = None,
  778. effective_period: str | None = None,
  779. invalid_mode: int | None = None,
  780. share_mode: int | None = None,
  781. ) -> dict:
  782. """
  783. 编辑自动额度发放规则
  784. 调用: alipay.ebpp.invoice.issuerule.modify
  785. """
  786. try:
  787. from alipay.aop.api.request.AlipayEbppInvoiceIssueruleModifyRequest import (
  788. AlipayEbppInvoiceIssueruleModifyRequest,
  789. )
  790. from alipay.aop.api.domain.AlipayEbppInvoiceIssueruleModifyModel import (
  791. AlipayEbppInvoiceIssueruleModifyModel,
  792. )
  793. from alipay.aop.api.response.AlipayEbppInvoiceIssueruleModifyResponse import (
  794. AlipayEbppInvoiceIssueruleModifyResponse,
  795. )
  796. except ImportError:
  797. raise CustomException(msg="支付宝SDK未正确安装(alipay-ebpp-invoice-issuerule-modify)")
  798. model = AlipayEbppInvoiceIssueruleModifyModel()
  799. model.target_type = "INSTITUTION"
  800. model.target_id = institution_id
  801. model.issue_rule_id = issue_rule_id
  802. model.action = "MODIFY_BASIC_INFO"
  803. model.enterprise_id = enterprise_id
  804. if issue_rule_name:
  805. model.issue_rule_name = issue_rule_name
  806. if quota_type:
  807. model.quota_type = quota_type
  808. if issue_type:
  809. model.issue_type = issue_type
  810. if issue_amount_value:
  811. model.issue_amount_value = issue_amount_value
  812. if effective is not None:
  813. model.effective = effective
  814. if effective_period:
  815. model.effective_period = effective_period
  816. if invalid_mode is not None:
  817. model.invalid_mode = invalid_mode
  818. if share_mode is not None:
  819. model.share_mode = share_mode
  820. request = AlipayEbppInvoiceIssueruleModifyRequest()
  821. request.biz_model = model
  822. response = await asyncio.to_thread(cls._execute_alipay, request)
  823. if not response:
  824. raise CustomException(msg="编辑发放规则失败: 无响应")
  825. result = AlipayEbppInvoiceIssueruleModifyResponse()
  826. result.parse_response_content(response)
  827. if not result.is_success():
  828. log.error(f"编辑发放规则失败: {result.code} - {result.msg}")
  829. raise CustomException(msg=f"编辑发放规则失败: {result.msg}")
  830. return {"result": True}
  831. @classmethod
  832. async def delete_issuerule_service(
  833. cls,
  834. auth: AuthSchema,
  835. institution_id: str,
  836. issue_rule_id_list: list[str],
  837. enterprise_id: str,
  838. ) -> dict:
  839. """
  840. 删除自动额度发放规则
  841. 调用: alipay.ebpp.invoice.issuerule.delete
  842. """
  843. try:
  844. from alipay.aop.api.request.AlipayEbppInvoiceIssueruleDeleteRequest import (
  845. AlipayEbppInvoiceIssueruleDeleteRequest,
  846. )
  847. from alipay.aop.api.domain.AlipayEbppInvoiceIssueruleDeleteModel import (
  848. AlipayEbppInvoiceIssueruleDeleteModel,
  849. )
  850. from alipay.aop.api.response.AlipayEbppInvoiceIssueruleDeleteResponse import (
  851. AlipayEbppInvoiceIssueruleDeleteResponse,
  852. )
  853. except ImportError:
  854. raise CustomException(msg="支付宝SDK未正确安装(alipay-ebpp-invoice-issuerule-delete)")
  855. model = AlipayEbppInvoiceIssueruleDeleteModel()
  856. model.target_type = "INSTITUTION"
  857. model.target_id = institution_id
  858. model.issue_rule_id_list = issue_rule_id_list
  859. model.enterprise_id = enterprise_id
  860. request = AlipayEbppInvoiceIssueruleDeleteRequest()
  861. request.biz_model = model
  862. response = await asyncio.to_thread(cls._execute_alipay, request)
  863. if not response:
  864. raise CustomException(msg="删除发放规则失败: 无响应")
  865. result = AlipayEbppInvoiceIssueruleDeleteResponse()
  866. result.parse_response_content(response)
  867. if not result.is_success():
  868. log.error(f"删除发放规则失败: {result.code} - {result.msg}")
  869. raise CustomException(msg=f"删除发放规则失败: {result.msg}")
  870. return {"result": True}