test_feishu.py 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538
  1. """飞书卡片:颜色、签名、日志、重试和超长正文。"""
  2. import asyncio
  3. import base64
  4. import hashlib
  5. import hmac
  6. import json
  7. from dataclasses import dataclass
  8. from datetime import date
  9. from decimal import Decimal
  10. import httpx
  11. import pytest
  12. from loguru import logger
  13. from pydantic import SecretStr
  14. from post_clear_check.chain import (
  15. CLEAR_MARK,
  16. CARD_DIMENSION,
  17. CHAIN_BUY,
  18. CHAIN_CASH_CHANGE,
  19. CHAIN_GATE,
  20. CHAIN_SELL,
  21. CHAIN_TRADE,
  22. CHAIN_UNASSIGNED,
  23. CHAINS,
  24. DIM_ORDER,
  25. DIM_SALES,
  26. )
  27. from post_clear_check.config import Settings
  28. from post_clear_check.feishu import (
  29. BODY_LIMIT,
  30. CARD_SUMMARY,
  31. DEFAULT_TIMEOUT_SECONDS,
  32. FeishuError,
  33. OutboundCard,
  34. RateGate,
  35. SummaryOwner,
  36. build_payload,
  37. card_verdict,
  38. classify_response,
  39. expectation,
  40. pack_bodies,
  41. render_body,
  42. render_summary_body,
  43. render_summary_title,
  44. render_title,
  45. send_card,
  46. send_summary,
  47. sign_payload,
  48. summary_verdict,
  49. )
  50. from post_clear_check.order_decimal import VERDICT_CORRECT, VERDICT_ERROR, VERDICT_SUSPECT
  51. from .support import FEISHU_SIGN_SECRET, FEISHU_WEBHOOK, sample_settings
  52. DAY = date(2026, 10, 15)
  53. ORDER_NO = "ORD001202610150006"
  54. WEBHOOK = "https://hook.invalid/marker"
  55. BUY_INVOLVED = CHAINS[CHAIN_BUY].involved()
  56. CASH_INVOLVED = CHAINS[CHAIN_CASH_CHANGE].involved()
  57. @dataclass(frozen=True)
  58. class Line:
  59. formula_text: str
  60. calc_text: str
  61. calc_value: Decimal | None
  62. check_text: str
  63. check_value: Decimal | None
  64. basis: str
  65. verdict: str
  66. def _line(
  67. formula: str,
  68. verdict: str,
  69. *,
  70. calc: str = "1 = 1",
  71. check: str = "列 = 1",
  72. calc_value: Decimal | None = Decimal("1"),
  73. check_value: Decimal | None = Decimal("1"),
  74. basis: str = "依据",
  75. ) -> Line:
  76. return Line(
  77. formula_text=formula,
  78. calc_text=calc,
  79. calc_value=calc_value,
  80. check_text=check,
  81. check_value=check_value,
  82. basis=basis,
  83. verdict=verdict,
  84. )
  85. def _card(
  86. lines: tuple[Line, ...] | list[Line],
  87. *,
  88. kind: str = "master_order",
  89. chain_name: str = CHAIN_BUY,
  90. biz_key: str = ORDER_NO,
  91. record_no: str = "81",
  92. batch_no: str = "B001",
  93. ) -> OutboundCard:
  94. return OutboundCard(
  95. card_kind=kind,
  96. chain_name=chain_name,
  97. biz_key=biz_key,
  98. business_date=DAY,
  99. trade_date=DAY,
  100. batch_no=batch_no,
  101. lines=tuple(lines),
  102. record_no=record_no,
  103. )
  104. def _settings(*, webhook: str = WEBHOOK, secret: str = "") -> Settings:
  105. base = sample_settings()
  106. return base.model_copy(
  107. update={
  108. "feishu_webhook": SecretStr(webhook),
  109. "feishu_sign_secret": SecretStr(secret),
  110. }
  111. )
  112. def _gate() -> RateGate:
  113. """每个用例一份配额,避免连着发把进程级窗口打满。"""
  114. return RateGate()
  115. def _transport(handler) -> httpx.MockTransport:
  116. return httpx.MockTransport(handler)
  117. def test_template_color_follows_card_verdict() -> None:
  118. """三种总结论分别用蓝、橙、红。混有错误时标题和颜色都是错误。"""
  119. samples = (
  120. (_line("公式甲", VERDICT_CORRECT), "blue", "正确"),
  121. (_line("公式乙", VERDICT_SUSPECT), "orange", "可疑"),
  122. (_line("公式丙", VERDICT_ERROR), "red", "错误"),
  123. )
  124. for line, color, label in samples:
  125. verdict = card_verdict((line,))
  126. title = render_title(verdict, CHAIN_BUY, "master_order", ORDER_NO)
  127. payload = build_payload(title=title, body="正文", verdict=verdict)
  128. assert payload["card"]["header"]["template"] == color
  129. assert title == f"{label} 买入执行 主订单 {ORDER_NO}"
  130. assert "correct" not in title
  131. assert "error" not in title
  132. assert "suspect" not in title
  133. mixed = card_verdict(
  134. (
  135. _line("对", VERDICT_CORRECT),
  136. _line("疑", VERDICT_SUSPECT),
  137. _line("错", VERDICT_ERROR),
  138. )
  139. )
  140. assert mixed == VERDICT_ERROR
  141. assert TEMPLATE_COLOR(mixed) == "red"
  142. suspect_card = card_verdict((_line("对", VERDICT_CORRECT), _line("疑", VERDICT_SUSPECT)))
  143. assert suspect_card == VERDICT_SUSPECT
  144. assert TEMPLATE_COLOR(suspect_card) == "orange"
  145. def TEMPLATE_COLOR(verdict: str) -> str:
  146. return build_payload(title="标题", body="正文", verdict=verdict)["card"]["header"]["template"]
  147. def test_sign_is_stable_hmac_of_empty_message() -> None:
  148. """签名字符串是时间戳、换行、密钥。消息为空字节,重复计算不变。"""
  149. timestamp = "1599360473"
  150. secret = "test-secret"
  151. first = sign_payload(timestamp, secret)
  152. second = sign_payload(timestamp, secret)
  153. key = f"{timestamp}\n{secret}".encode("utf-8")
  154. expected = base64.b64encode(hmac.new(key, b"", hashlib.sha256).digest()).decode("ascii")
  155. assert first == second == expected
  156. assert sign_payload("1599360474", secret) != first
  157. def test_log_omits_webhook_and_secret() -> None:
  158. """网络异常的原文里即使带了地址和密钥,日志里也不能留下。"""
  159. lines: list[str] = []
  160. handle = logger.add(lambda message: lines.append(str(message)), diagnose=False, backtrace=False)
  161. def handler(_request: httpx.Request) -> httpx.Response:
  162. raise httpx.ConnectError(f"连接失败 {WEBHOOK} {FEISHU_SIGN_SECRET}")
  163. async def run() -> None:
  164. result = await send_card(
  165. _settings(webhook=WEBHOOK, secret=FEISHU_SIGN_SECRET),
  166. _card((_line("公式", VERDICT_ERROR),)),
  167. transport=_transport(handler),
  168. gate=_gate(),
  169. )
  170. assert result.ok is False
  171. assert result.retryable is True
  172. assert result.attempted is True
  173. try:
  174. asyncio.run(run())
  175. finally:
  176. logger.remove(handle)
  177. text = "".join(lines)
  178. assert WEBHOOK not in text
  179. assert "hook.invalid" not in text
  180. assert FEISHU_SIGN_SECRET not in text
  181. assert "https://" not in text
  182. assert "http://" not in text
  183. assert "连接失败" in text
  184. assert "可重试" in text
  185. def test_http_429_is_retryable() -> None:
  186. """429 可以再发。签名业务码即使写在 429 的正文里,也仍然可重试。"""
  187. def handler(_request: httpx.Request) -> httpx.Response:
  188. return httpx.Response(429, json={"code": 19021})
  189. result = asyncio.run(
  190. send_card(
  191. _settings(),
  192. _card((_line("公式", VERDICT_ERROR),)),
  193. transport=_transport(handler),
  194. gate=_gate(),
  195. )
  196. )
  197. assert result.ok is False
  198. assert result.retryable is True
  199. assert result.attempted is True
  200. @pytest.mark.parametrize("code", [19021, 19022, 19024, 999])
  201. def test_business_code_is_not_retryable(code: int) -> None:
  202. """签名失败和其他非 0 业务码都不重试。"""
  203. ok, retryable, found = classify_response(200, json.dumps({"code": code}).encode())
  204. assert ok is False
  205. assert retryable is False
  206. assert found == code
  207. def test_status_code_zero_is_success() -> None:
  208. """没有 code 时,StatusCode 为 0 也算成功。"""
  209. ok, retryable, found = classify_response(
  210. 200,
  211. json.dumps({"StatusCode": 0, "StatusMessage": "success"}).encode(),
  212. )
  213. assert (ok, retryable, found) == (True, False, 0)
  214. ok, retryable, found = classify_response(200, json.dumps({"code": 0, "msg": "success"}).encode())
  215. assert (ok, retryable, found) == (True, False, 0)
  216. def test_http_5xx_is_retryable_and_4xx_is_not() -> None:
  217. """5xx 可重试。400 不是网络失败,也不在可重试的状态里。"""
  218. assert classify_response(503, b"")[1] is True
  219. assert classify_response(500, b"upstream")[1] is True
  220. ok, retryable, _found = classify_response(400, json.dumps({"code": 0}).encode())
  221. assert ok is False
  222. assert retryable is False
  223. def test_long_body_keeps_every_error() -> None:
  224. """正确覆盖块从末尾整块删。错误、可疑和开头四行留下,并写上记录编号。"""
  225. head = Line(
  226. formula_text="头正确",
  227. calc_text="1 = 1",
  228. calc_value=Decimal("1"),
  229. check_text="列 = 1",
  230. check_value=Decimal("1"),
  231. basis="依据甲",
  232. verdict=VERDICT_CORRECT,
  233. )
  234. first_error = _line("错误甲", VERDICT_ERROR)
  235. long_correct = Line(
  236. formula_text="长正确" + "测" * 4000,
  237. calc_text="1 = 1",
  238. calc_value=Decimal("1"),
  239. check_text="列 = 1",
  240. check_value=Decimal("1"),
  241. basis="依据乙",
  242. verdict=VERDICT_CORRECT,
  243. )
  244. tail = Line(
  245. formula_text="尾正确",
  246. calc_text="1 = 1",
  247. calc_value=Decimal("1"),
  248. check_text="列 = 1",
  249. check_value=Decimal("1"),
  250. basis="依据乙",
  251. verdict=VERDICT_CORRECT,
  252. )
  253. second_error = _line("错误乙", VERDICT_ERROR)
  254. suspect = _line("可疑甲", VERDICT_SUSPECT)
  255. body = render_body(
  256. business_date=DAY,
  257. trade_date=DAY,
  258. batch_no="B001",
  259. chain_name=CHAIN_BUY,
  260. lines=(head, first_error, long_correct, tail, second_error, suspect),
  261. record_no="81",
  262. )
  263. assert len(body) <= BODY_LIMIT
  264. assert "错误甲" in body
  265. assert "错误乙" in body
  266. assert "可疑甲" in body
  267. assert "头正确" in body
  268. assert "尾正确" not in body
  269. assert "长正确" not in body
  270. assert "另有 2 项未写入本卡。" in body
  271. assert "记录编号:81" in body
  272. # 删块以覆盖块为单位,开头四行(含「涉及」)保留。
  273. assert body.startswith(f"营业日:2026-10-15\n交易日:2026-10-15\n批次号:B001\n涉及:{BUY_INVOLVED}\n\n")
  274. assert "不符合预期。" in body
  275. assert "无法判定。" in body
  276. assert "结论:正确" in body
  277. huge_errors = tuple(_line(f"错误{index}-" + "错" * 500, VERDICT_ERROR) for index in range(10))
  278. huge = render_body(
  279. business_date=DAY,
  280. trade_date=DAY,
  281. batch_no="B001",
  282. chain_name=CHAIN_BUY,
  283. lines=huge_errors,
  284. record_no="82",
  285. )
  286. assert len(huge) > BODY_LIMIT
  287. for index in range(10):
  288. assert f"错误{index}-" in huge
  289. assert "记录编号:82" in huge
  290. def test_short_body_matches_template_without_record_no() -> None:
  291. """订单卡没超长时不写记录编号。报表行进关联报表,五行之间空一行。"""
  292. line = Line(
  293. formula_text="总金额 = 票据发行价 + 承销费用",
  294. calc_text="1001000.00 + 0.00 = 1001000.00;同一主订单的执行值",
  295. calc_value=Decimal("1001000.000000"),
  296. check_text="OTC003 共赢票据订单报表.总金额(结算币种) = 1001000.00",
  297. check_value=Decimal("1001000.000000"),
  298. basis="共赢票据重要字段、计算公式等说明V2 工作表「买入订单」第 28 行「总金额」",
  299. verdict=VERDICT_CORRECT,
  300. )
  301. body = render_body(
  302. business_date=DAY,
  303. trade_date=DAY,
  304. batch_no="B001",
  305. chain_name=CHAIN_BUY,
  306. lines=(line,),
  307. record_no="81",
  308. )
  309. assert body == (
  310. "营业日:2026-10-15\n"
  311. "交易日:2026-10-15\n"
  312. "批次号:B001\n"
  313. f"涉及:{BUY_INVOLVED}\n"
  314. "\n"
  315. "关联报表\n"
  316. "\n"
  317. "字段:总金额\n"
  318. "\n"
  319. "期望:1001000.00 + 0.00 = 1001000.00\n"
  320. "\n"
  321. "实据:OTC003 共赢票据订单报表.总金额(结算币种) = 1001000.00\n"
  322. "\n"
  323. "依据:共赢票据重要字段、计算公式等说明V2 工作表「买入订单」第 28 行\n"
  324. "\n"
  325. "结论:正确。同一主订单的执行值。"
  326. )
  327. assert "记录编号" not in body
  328. def test_gate_and_unassigned_involve_the_tool_itself() -> None:
  329. """核对门禁和未归链路都写核对工具自身。门禁另写已经挂上的步骤,未归链路不写步骤。"""
  330. gate = render_body(
  331. business_date=DAY,
  332. trade_date=DAY,
  333. batch_no="B001",
  334. chain_name=CHAIN_GATE,
  335. lines=(_line("公式", VERDICT_ERROR),),
  336. record_no="81",
  337. )
  338. assert "\n涉及:核对工具自身;" in gate
  339. assert "发行端清算步骤1 开启清算" in gate
  340. unassigned = render_body(
  341. business_date=DAY,
  342. trade_date=DAY,
  343. batch_no="B001",
  344. chain_name=CHAIN_UNASSIGNED,
  345. lines=(_line("公式", VERDICT_ERROR),),
  346. record_no="81",
  347. )
  348. assert "\n涉及:核对工具自身\n" in unassigned
  349. assert "清算步骤" not in unassigned
  350. def test_fourth_line_sentences() -> None:
  351. """第四行四种句式:正确、错误、期望值无法计算、只看出现与否。可疑不下符合与否的结论。"""
  352. correct = _line("甲", VERDICT_CORRECT, calc_value=Decimal("779578.24"), check_value=Decimal("779578.240000"))
  353. assert expectation(correct) == "期望 779578.24,当前 779578.24。符合预期。"
  354. wrong = _line("乙", VERDICT_ERROR, calc_value=Decimal("-220421.76"), check_value=Decimal("-220400"))
  355. assert expectation(wrong) == "期望 -220421.76,当前 -220400.00。不符合预期。"
  356. blind = _line("丙", VERDICT_SUSPECT, calc_value=None, check_value=Decimal("580"))
  357. assert expectation(blind) == "期望值无法计算,当前 580.00。"
  358. present = _line("丁", VERDICT_CORRECT, calc_value=None, check_value=None)
  359. assert expectation(present) == "期望与当前一致。符合预期。"
  360. absent = _line("戊", VERDICT_ERROR, calc_value=Decimal("5"), check_value=None)
  361. assert expectation(absent) == "期望 5.00,当前缺失。不符合预期。"
  362. undecided = _line("己", VERDICT_SUSPECT, calc_value=Decimal("1.234567"), check_value=Decimal("1.2346"))
  363. assert expectation(undecided) == "期望 1.234567,当前 1.2346。无法判定。"
  364. nothing = _line("庚", VERDICT_SUSPECT, calc_value=None, check_value=None)
  365. assert expectation(nothing) == "期望值无法计算,当前缺失。"
  366. def test_chinese_under_character_limit_is_not_cut() -> None:
  367. """上限按字符数,不按 UTF-8 字节数。"""
  368. formula = "正" * 2000
  369. body = render_body(
  370. business_date=DAY,
  371. trade_date=DAY,
  372. batch_no="B001",
  373. chain_name=CHAIN_BUY,
  374. lines=(_line(formula, VERDICT_CORRECT),),
  375. record_no="81",
  376. )
  377. assert formula in body
  378. assert "记录编号" not in body
  379. assert len(body) <= BODY_LIMIT
  380. assert len(body.encode("utf-8")) > BODY_LIMIT
  381. def test_title_is_verdict_chain_owner_and_key() -> None:
  382. """标题顺序是结论、链路名称、归属名称、主键值。不写系统和清算步骤,失败重发末尾加「重发」。"""
  383. assert render_title(VERDICT_ERROR, CHAIN_BUY, "master_order", "ORD001202610210004") == (
  384. "错误 买入执行 主订单 ORD001202610210004"
  385. )
  386. assert render_title(VERDICT_CORRECT, CHAIN_CASH_CHANGE, "fund_account", "FA00012345") == (
  387. "正确 资金变动 资金账户 FA00012345"
  388. )
  389. assert render_title(VERDICT_SUSPECT, CHAIN_TRADE, "trs_user", "U0001") == "可疑 委托执行 用户账号 U0001"
  390. assert render_title(VERDICT_ERROR, CHAIN_GATE, "clearing_batch", "CB20261021001") == (
  391. "错误 核对门禁 清算批次 CB20261021001"
  392. )
  393. assert render_title(VERDICT_ERROR, CHAIN_UNASSIGNED, "clearing_batch", "2026-10-21") == (
  394. "错误 未归链路 清算批次 2026-10-21"
  395. )
  396. assert render_title(VERDICT_ERROR, CHAIN_BUY, "master_order", "ORD001202610210004", resend=True) == (
  397. "错误 买入执行 主订单 ORD001202610210004 重发"
  398. )
  399. assert "重发" not in render_title(VERDICT_ERROR, CHAIN_BUY, "master_order", ORDER_NO)
  400. for title in (render_title(VERDICT_ERROR, CHAIN_BUY, "master_order", ORDER_NO),):
  401. assert "清算步骤" not in title
  402. assert "发行端" not in title
  403. with pytest.raises(FeishuError, match="链路"):
  404. render_title(VERDICT_ERROR, "", "master_order", ORDER_NO)
  405. def test_redirect_is_not_followed() -> None:
  406. """302 只看到一次请求,并且不可重试。"""
  407. calls: list[str] = []
  408. def handler(request: httpx.Request) -> httpx.Response:
  409. calls.append(str(request.url))
  410. return httpx.Response(302, headers={"Location": "https://hook.invalid/other"})
  411. result = asyncio.run(
  412. send_card(
  413. _settings(),
  414. _card((_line("公式", VERDICT_CORRECT),)),
  415. transport=_transport(handler),
  416. gate=_gate(),
  417. )
  418. )
  419. assert calls == [WEBHOOK]
  420. assert result.ok is False
  421. assert result.retryable is False
  422. def test_client_timeout_defaults_to_ten_seconds(monkeypatch: pytest.MonkeyPatch) -> None:
  423. """默认超时 10 秒,不跟随重定向,不走环境变量里的代理。"""
  424. captured: dict[str, object] = {}
  425. real = httpx.AsyncClient
  426. def factory(*_args: object, **kwargs: object) -> httpx.AsyncClient:
  427. captured["timeout"] = kwargs["timeout"]
  428. captured["follow_redirects"] = kwargs["follow_redirects"]
  429. captured["trust_env"] = kwargs["trust_env"]
  430. kwargs["transport"] = _transport(lambda _request: httpx.Response(200, json={"code": 0}))
  431. return real(**kwargs) # type: ignore[arg-type]
  432. monkeypatch.setattr("post_clear_check.feishu.httpx.AsyncClient", factory)
  433. result = asyncio.run(
  434. send_card(
  435. _settings(),
  436. _card((_line("公式", VERDICT_CORRECT),)),
  437. gate=_gate(),
  438. )
  439. )
  440. assert result.ok is True
  441. assert DEFAULT_TIMEOUT_SECONDS == 10
  442. assert captured["follow_redirects"] is False
  443. assert captured["trust_env"] is False
  444. assert captured["timeout"] == httpx.Timeout(10.0)
  445. asyncio.run(
  446. send_card(
  447. _settings(),
  448. _card((_line("公式", VERDICT_CORRECT),)),
  449. timeout_seconds=2.5,
  450. gate=_gate(),
  451. )
  452. )
  453. assert captured["timeout"] == httpx.Timeout(2.5)
  454. def test_signed_payload_uses_stable_sign_and_hides_secret() -> None:
  455. """有密钥时正文带 timestamp 和 sign,报文里没有密钥和 Webhook。"""
  456. seen: list[dict[str, object]] = []
  457. def handler(request: httpx.Request) -> httpx.Response:
  458. seen.append(json.loads(request.content.decode("utf-8")))
  459. return httpx.Response(200, json={"code": 0})
  460. result = asyncio.run(
  461. send_card(
  462. _settings(secret="test-secret"),
  463. _card((_line("公式", VERDICT_ERROR),)),
  464. transport=_transport(handler),
  465. gate=_gate(),
  466. clock=lambda: 1599360473,
  467. )
  468. )
  469. assert result.ok is True
  470. payload = seen[0]
  471. assert payload["timestamp"] == "1599360473"
  472. assert payload["sign"] == sign_payload("1599360473", "test-secret")
  473. assert payload["msg_type"] == "interactive"
  474. encoded = json.dumps(payload, ensure_ascii=False)
  475. assert "test-secret" not in encoded
  476. assert WEBHOOK not in encoded
  477. assert payload["card"]["header"]["template"] == "red"
  478. def test_unsigned_payload_has_no_timestamp() -> None:
  479. """没配密钥时不签名。"""
  480. seen: list[dict[str, object]] = []
  481. def handler(request: httpx.Request) -> httpx.Response:
  482. seen.append(json.loads(request.content.decode("utf-8")))
  483. return httpx.Response(200, json={"StatusCode": 0})
  484. result = asyncio.run(
  485. send_card(
  486. _settings(secret=""),
  487. _card((_line("公式", VERDICT_SUSPECT),), kind="fund_account", chain_name=CHAIN_CASH_CHANGE, biz_key="FA001"),
  488. transport=_transport(handler),
  489. gate=_gate(),
  490. )
  491. )
  492. assert result.ok is True
  493. assert "timestamp" not in seen[0]
  494. assert "sign" not in seen[0]
  495. assert seen[0]["card"]["header"]["title"]["content"] == "可疑 资金变动 资金账户 FA001"
  496. assert seen[0]["card"]["config"] == {"wide_screen_mode": True}
  497. def test_empty_lines_are_not_sent() -> None:
  498. """没有结论行的卡片不发请求。"""
  499. called = False
  500. def handler(_request: httpx.Request) -> httpx.Response:
  501. nonlocal called
  502. called = True
  503. return httpx.Response(200, json={"code": 0})
  504. result = asyncio.run(
  505. send_card(
  506. _settings(),
  507. _card(()),
  508. transport=_transport(handler),
  509. gate=_gate(),
  510. )
  511. )
  512. assert called is False
  513. assert result.attempted is False
  514. assert result.ok is False
  515. assert result.retryable is False
  516. def test_bad_url_is_not_retryable_and_log_has_no_address() -> None:
  517. """协议不对不是网络失败。异常原文里的地址也不能进日志。"""
  518. lines: list[str] = []
  519. handle = logger.add(lambda message: lines.append(str(message)), diagnose=False, backtrace=False)
  520. webhook = "ftp://hook.invalid/marker"
  521. try:
  522. result = asyncio.run(
  523. send_card(
  524. _settings(webhook=webhook, secret=FEISHU_SIGN_SECRET),
  525. _card((_line("公式", VERDICT_ERROR),)),
  526. gate=_gate(),
  527. )
  528. )
  529. finally:
  530. logger.remove(handle)
  531. assert result.ok is False
  532. assert result.retryable is False
  533. assert result.attempted is True
  534. text = "".join(lines)
  535. assert webhook not in text
  536. assert "hook.invalid" not in text
  537. assert FEISHU_SIGN_SECRET not in text
  538. assert "不可重试" in text
  539. def test_invalid_verdict_timeout_and_kind_raise_without_request() -> None:
  540. """结论、超时、主键名称在发出请求前就拦住。"""
  541. called = False
  542. def handler(_request: httpx.Request) -> httpx.Response:
  543. nonlocal called
  544. called = True
  545. return httpx.Response(200, json={"code": 0})
  546. with pytest.raises(FeishuError, match="结论不在"):
  547. asyncio.run(
  548. send_card(
  549. _settings(),
  550. _card((_line("公式", "bad"),)),
  551. transport=_transport(handler),
  552. gate=_gate(),
  553. )
  554. )
  555. with pytest.raises(FeishuError, match="超时必须大于 0"):
  556. asyncio.run(
  557. send_card(
  558. _settings(),
  559. _card((_line("公式", VERDICT_ERROR),)),
  560. timeout_seconds=0,
  561. transport=_transport(handler),
  562. gate=_gate(),
  563. )
  564. )
  565. with pytest.raises(FeishuError, match="主键名称"):
  566. asyncio.run(
  567. send_card(
  568. _settings(),
  569. _card((_line("公式", VERDICT_ERROR),), kind="note"),
  570. transport=_transport(handler),
  571. gate=_gate(),
  572. )
  573. )
  574. assert called is False
  575. def test_missing_webhook_error_has_no_secret() -> None:
  576. """没配 Webhook 时的异常文本不带密钥。"""
  577. with pytest.raises(FeishuError, match="未配置飞书 Webhook") as caught:
  578. asyncio.run(
  579. send_card(
  580. _settings(webhook=" ", secret=FEISHU_SIGN_SECRET),
  581. _card((_line("公式", VERDICT_ERROR),)),
  582. gate=_gate(),
  583. )
  584. )
  585. assert FEISHU_SIGN_SECRET not in str(caught.value)
  586. assert FEISHU_WEBHOOK not in str(caught.value)
  587. def test_rate_gate_waits_instead_of_dropping() -> None:
  588. """同一秒第 6 次会等到窗口滑开。一分钟第 101 次同样等待,不丢。"""
  589. now = {"t": 0.0}
  590. def clock() -> float:
  591. return now["t"]
  592. async def sleep(seconds: float) -> None:
  593. now["t"] += seconds
  594. gate = RateGate(clock=clock, sleep=sleep)
  595. async def six() -> None:
  596. for _ in range(6):
  597. await gate.acquire()
  598. asyncio.run(six())
  599. assert now["t"] == pytest.approx(1.0)
  600. now["t"] = 0.0
  601. gate = RateGate(clock=clock, sleep=sleep)
  602. async def hundred_one() -> None:
  603. for _ in range(101):
  604. await gate.acquire()
  605. asyncio.run(hundred_one())
  606. assert now["t"] == pytest.approx(60.0)
  607. def test_error_theme_posts_only_error_rows() -> None:
  608. """主题是错误时,正文只有错误清单。正确和可疑不出现,摘要说明它们留在核对明细。"""
  609. seen: list[dict[str, object]] = []
  610. def handler(request: httpx.Request) -> httpx.Response:
  611. seen.append(json.loads(request.content.decode("utf-8")))
  612. return httpx.Response(200, json={"code": 0})
  613. lines = (
  614. _line("正确公式", VERDICT_CORRECT, calc="2 + 2 = 4;这是正确说明", check="报表.甲 = 4"),
  615. _line(
  616. "错误公式",
  617. VERDICT_ERROR,
  618. calc="3 + 1 = 4;这是错误说明",
  619. check="报表.乙 = 9",
  620. calc_value=Decimal("4"),
  621. check_value=Decimal("9"),
  622. ),
  623. _line("可疑公式", VERDICT_SUSPECT, calc="字段缺失", calc_value=None, check_value=Decimal("1")),
  624. )
  625. result = asyncio.run(
  626. send_card(
  627. _settings(),
  628. _card(lines),
  629. transport=_transport(handler),
  630. gate=_gate(),
  631. )
  632. )
  633. assert result.ok is True
  634. assert len(seen) == 1
  635. card = seen[0]["card"]
  636. assert card["header"]["title"]["content"] == f"错误 买入执行 主订单 {ORDER_NO}"
  637. text = card["elements"][0]["text"]["content"]
  638. assert "错误公式" in text
  639. assert "正确公式" not in text
  640. assert "可疑公式" not in text
  641. assert "。符合预期。" not in text
  642. assert "无法判定。" not in text
  643. assert "正确与可疑留在核对明细。" in text
  644. assert "\n\n每单链路\n\n" not in text
  645. assert "\n\n盘中公式\n\n" not in text
  646. assert (
  647. "关联报表\n\n"
  648. "字段:错误公式\n\n"
  649. "期望:3 + 1 = 4\n\n"
  650. "实据:报表.乙 = 9。当前 9.00\n\n"
  651. "依据:依据\n\n"
  652. "结论:错误。不符合预期。这是错误说明。"
  653. ) in text
  654. def test_suspect_theme_still_includes_correct_rows() -> None:
  655. """主题是可疑时,正文仍带上收敛后的正确行。只对错误主题收成错误清单。"""
  656. lines = (
  657. _line("正确公式", VERDICT_CORRECT),
  658. _line("可疑公式", VERDICT_SUSPECT, calc_value=Decimal("1"), check_value=Decimal("2")),
  659. )
  660. bodies = pack_bodies(
  661. title=render_title(VERDICT_SUSPECT, CHAIN_BUY, "master_order", ORDER_NO),
  662. verdict=VERDICT_SUSPECT,
  663. business_date=DAY,
  664. trade_date=DAY,
  665. batch_no="B001",
  666. chain_name=CHAIN_BUY,
  667. record_no="81",
  668. lines=lines,
  669. signed=False,
  670. )
  671. assert len(bodies) == 1
  672. assert "\n\n盘中公式\n\n" in bodies[0]
  673. assert "字段:正确公式" in bodies[0]
  674. assert "字段:可疑公式" in bodies[0]
  675. assert "结论:正确" in bodies[0]
  676. assert "结论:可疑。无法判定。" in bodies[0]
  677. assert "正确与可疑留在核对明细。" not in bodies[0]
  678. def test_send_posts_json_shape() -> None:
  679. """发出去的卡片含宽屏、标题和一条 lark_md 正文。"""
  680. seen: list[dict[str, object]] = []
  681. def handler(request: httpx.Request) -> httpx.Response:
  682. seen.append(json.loads(request.content.decode("utf-8")))
  683. return httpx.Response(200, json={"code": 0})
  684. asyncio.run(
  685. send_card(
  686. _settings(),
  687. _card(
  688. (
  689. _line(
  690. "票据面值(结算币种) + 发行费用(结算币种)",
  691. VERDICT_CORRECT,
  692. calc="1000000.00 + 1000.00 = 1001000.00",
  693. check="OTC003 共赢票据订单报表.总金额(结算币种) = 1001000.00",
  694. ),
  695. )
  696. ),
  697. transport=_transport(handler),
  698. gate=_gate(),
  699. )
  700. )
  701. card = seen[0]["card"]
  702. assert card["elements"] == [
  703. {
  704. "tag": "div",
  705. "text": {
  706. "tag": "lark_md",
  707. "content": card["elements"][0]["text"]["content"],
  708. },
  709. }
  710. ]
  711. text = card["elements"][0]["text"]["content"]
  712. assert text.startswith(f"营业日:2026-10-15\n交易日:2026-10-15\n批次号:B001\n涉及:{BUY_INVOLVED}\n")
  713. assert "字段:票据面值(结算币种) + 发行费用(结算币种)" in text
  714. assert "\n\n关联报表\n\n" in text
  715. assert "结论:正确" in text
  716. assert seen[0]["card"]["header"]["title"]["content"] == f"正确 买入执行 主订单 {ORDER_NO}"
  717. assert len(seen) == 1
  718. def test_same_report_is_one_section_and_errors_come_first() -> None:
  719. """错误清单在正确覆盖块前面。同一依据去掉列名。同行等式说明不重复。"""
  720. basis = "产品原型 报表.md 「列」"
  721. correct = Line(
  722. formula_text="价格 = 100",
  723. calc_text="100;发行端 OTC003 共赢票据订单报表同行等式",
  724. calc_value=Decimal("100"),
  725. check_text="OTC003 共赢票据订单报表.价格 = 100.00(主订单编号 ORD1)",
  726. check_value=Decimal("100"),
  727. basis=basis,
  728. verdict=VERDICT_CORRECT,
  729. )
  730. wrong = Line(
  731. formula_text="股票数量 = 名义本金(交易币种) / 成交股价",
  732. calc_text="1 / 2 = 0.50;回写 after_val 优先",
  733. calc_value=Decimal("0.50"),
  734. check_text="OTC003 共赢票据订单报表.股票数量 = 9.00(主订单编号 ORD1)",
  735. check_value=Decimal("9"),
  736. basis="产品原型 报表.md 「股票数量」",
  737. verdict=VERDICT_ERROR,
  738. )
  739. body = render_body(
  740. business_date=DAY,
  741. trade_date=DAY,
  742. batch_no="B001",
  743. chain_name=CHAIN_BUY,
  744. lines=(correct, wrong),
  745. record_no="81",
  746. )
  747. assert body.index("股票数量") < body.index("价格 = 100")
  748. assert "\n\n关联报表\n\n" in body
  749. assert body.count("依据:产品原型 报表.md") == 2
  750. assert "主订单编号 ORD1" in body
  751. assert "同行等式" not in body
  752. assert "回写 after_val 优先" in body
  753. assert "代入:100" not in body
  754. assert "被核对的对象" not in body
  755. def test_chapters_keep_blocked_rows_and_not_applicable_rows() -> None:
  756. """期望值为空的可疑进行无法展开。公式里写了不出现的正确行进行不适用。开头四行仍在。"""
  757. correct = _line(
  758. "价格 = 100",
  759. VERDICT_CORRECT,
  760. check="OTC003 订单.价格 = 100(主订单编号 ORD1)",
  761. basis="产品原型 报表.md 「价格」",
  762. )
  763. other = _line(
  764. "数量 = 2",
  765. VERDICT_CORRECT,
  766. check="OTC003 订单.数量 = 2(主订单编号 ORD1)",
  767. basis="产品原型 报表.md 「数量」",
  768. )
  769. absent = _line(
  770. "金额为 0 的冻结必须不出现",
  771. VERDICT_CORRECT,
  772. calc="未出现",
  773. calc_value=None,
  774. check_value=None,
  775. )
  776. suspect = _line("结算汇率", VERDICT_SUSPECT, calc_value=Decimal("1.56"), check_value=Decimal("1.17"))
  777. blocked = _line("实时估值", VERDICT_SUSPECT, calc="最新价缺失", calc_value=None, check_value=None)
  778. wrong = _line("总金额", VERDICT_ERROR, calc_value=Decimal("1"), check_value=Decimal("2"))
  779. body = render_body(
  780. business_date=DAY,
  781. trade_date=DAY,
  782. batch_no="B001",
  783. chain_name=CHAIN_CASH_CHANGE,
  784. lines=(correct, other, absent, suspect, blocked, wrong),
  785. record_no="81",
  786. sample_index=2,
  787. )
  788. assert body.startswith(
  789. f"抽样明细第 2 条\n营业日:2026-10-15\n交易日:2026-10-15\n批次号:B001\n涉及:{CASH_INVOLVED}\n\n"
  790. )
  791. assert body.index("\n\n错误清单\n\n") < body.index("\n\n可疑清单\n\n")
  792. assert body.index("\n\n可疑清单\n\n") < body.index("\n\n无法展开\n\n")
  793. assert body.index("\n\n无法展开\n\n") < body.index("\n\n正确\n\n")
  794. assert body.index("\n\n正确\n\n") < body.index("\n\n不适用\n\n")
  795. suspect_part = body.split("\n\n可疑清单\n\n", 1)[1].split("\n\n无法展开\n\n", 1)[0]
  796. blocked_part = body.split("\n\n无法展开\n\n", 1)[1].split("\n\n正确\n\n", 1)[0]
  797. correct_part = body.split("\n\n正确\n\n", 1)[1].split("\n\n不适用\n\n", 1)[0]
  798. absent_part = body.split("\n\n不适用\n\n", 1)[1]
  799. assert "总金额" in body.split("\n\n错误清单\n\n", 1)[1].split("\n\n可疑清单\n\n", 1)[0]
  800. assert "结算汇率" in suspect_part
  801. assert "实时估值" not in suspect_part
  802. assert "实时估值" in blocked_part
  803. assert "期望值无法计算" in blocked_part
  804. assert "价格 = 100" in correct_part
  805. assert "数量 = 2" in correct_part
  806. assert "2 列无差异" in correct_part
  807. assert "筛选:主订单 ORD1" in correct_part
  808. assert "必须不出现" not in correct_part
  809. assert "必须不出现" in absent_part
  810. assert "结论:正确" in absent_part
  811. assert "本卡正确 3,错误 1,可疑 2。" in body
  812. def test_order_chain_groups_link_formula_clear_and_report() -> None:
  813. """买入、卖出明细按四章排。每单链路只写结论,计算按字段列出。"""
  814. lines = (
  815. _line("执行事实列与源表一致", VERDICT_CORRECT, calc="一致", check="源表一致"),
  816. _line(
  817. "对冲记录成交股数 = TRS 执行根的成交股数",
  818. VERDICT_CORRECT,
  819. calc="600 = 600",
  820. check="成交股数 = 600",
  821. ),
  822. _line(
  823. "票据面值 = 执行订单名义本金(结算币种) × A 类报价",
  824. VERDICT_CORRECT,
  825. calc="100 × 10% = 10",
  826. check="面值 = 10",
  827. ),
  828. _line("T日订单结算与工作快照一致", VERDICT_CORRECT, calc="一致", check="cleared 一致"),
  829. _line(
  830. "总金额 = 票据发行价 + 承销费用",
  831. VERDICT_CORRECT,
  832. calc="10 + 1 = 11",
  833. check="OTC003 共赢票据订单报表.总金额 = 11",
  834. ),
  835. )
  836. body = render_body(
  837. business_date=DAY,
  838. trade_date=DAY,
  839. batch_no="B001",
  840. chain_name=CHAIN_BUY,
  841. lines=lines,
  842. record_no="81",
  843. )
  844. assert body.index("\n\n每单链路\n\n") < body.index("\n\n盘中公式\n\n")
  845. assert body.index("\n\n盘中公式\n\n") < body.index("\n\n清算对照\n\n")
  846. assert body.index("\n\n清算对照\n\n") < body.index("\n\n关联报表\n\n")
  847. assert "结论:正确。执行事实列与源表一致。对冲记录成交股数等于 TRS 执行根的成交股数。" in body
  848. assert "字段:执行事实列与源表一致" not in body
  849. assert "字段:票据面值" in body
  850. assert "字段:T日订单结算与工作快照一致" in body
  851. assert "字段:总金额" in body
  852. sell = render_body(
  853. business_date=DAY,
  854. trade_date=DAY,
  855. batch_no="B001",
  856. chain_name=CHAIN_SELL,
  857. lines=lines[2:3],
  858. record_no="81",
  859. )
  860. assert "\n\n盘中公式\n\n" in sell
  861. assert "\n\n每单链路\n\n" not in sell
  862. def test_sample_line_is_the_first_body_line() -> None:
  863. """配了推送条数时,抽样序号写在营业日前面。"""
  864. body = render_body(
  865. business_date=DAY,
  866. trade_date=DAY,
  867. batch_no="B001",
  868. chain_name=CHAIN_BUY,
  869. lines=(_line("公式", VERDICT_CORRECT),),
  870. record_no="81",
  871. sample_index=1,
  872. )
  873. assert body.startswith("抽样明细第 1 条\n营业日:2026-10-15\n")
  874. def test_body_strips_control_characters_and_angle_brackets() -> None:
  875. """制表符去掉,尖括号改成全角,避免 lark_md 当成标签。"""
  876. body = render_body(
  877. business_date=DAY,
  878. trade_date=DAY,
  879. batch_no="B001",
  880. chain_name=CHAIN_BUY,
  881. lines=(_line("a<b>\tc", VERDICT_ERROR, calc="1\t2", check="报<表>.甲 = 1"),),
  882. record_no="81",
  883. )
  884. assert "\t" not in body
  885. assert "<" not in body
  886. assert ">" not in body
  887. assert "a<b>c" in body
  888. assert "报<表>" in body
  889. def test_failure_log_includes_feishu_msg_without_webhook() -> None:
  890. """业务码失败时带上飞书 msg,地址仍不落日志。"""
  891. lines: list[str] = []
  892. handle = logger.add(lambda message: lines.append(str(message)), diagnose=False, backtrace=False)
  893. def handler(_request: httpx.Request) -> httpx.Response:
  894. return httpx.Response(200, json={"code": 11246, "msg": f"invalid tag {WEBHOOK}"})
  895. async def run() -> None:
  896. result = await send_card(
  897. _settings(webhook=WEBHOOK),
  898. _card((_line("公式", VERDICT_ERROR),)),
  899. transport=_transport(handler),
  900. gate=_gate(),
  901. )
  902. assert result.ok is False
  903. assert result.retryable is False
  904. assert result.attempted is True
  905. try:
  906. asyncio.run(run())
  907. finally:
  908. logger.remove(handle)
  909. text = "".join(lines)
  910. assert "11246" in text
  911. assert "不可重试" in text
  912. assert "invalid tag" in text
  913. assert WEBHOOK not in text
  914. assert "https://" not in text
  915. assert "推送类型未配置,三类都推" in text
  916. def test_oversized_errors_split_into_posts_under_byte_limit() -> None:
  917. """错误行全部保留,超限时拆成多张,每张请求体不超过给定字节。"""
  918. lines = tuple(_line(f"标记{index}" + "错" * 80, VERDICT_ERROR) for index in range(4))
  919. title = render_title(VERDICT_ERROR, CHAIN_BUY, "master_order", ORDER_NO)
  920. limit = 2200
  921. bodies = pack_bodies(
  922. title=title,
  923. verdict=VERDICT_ERROR,
  924. business_date=DAY,
  925. trade_date=DAY,
  926. batch_no="B001",
  927. chain_name=CHAIN_BUY,
  928. record_no="81",
  929. lines=lines,
  930. signed=True,
  931. limit=limit,
  932. )
  933. assert len(bodies) >= 2
  934. seen: set[str] = set()
  935. for index, body in enumerate(bodies, start=1):
  936. assert f"第{index}张,共{len(bodies)}张" in body
  937. assert "记录编号:81" in body
  938. # 拆分以段为单位,每张都保留开头四行(含「涉及」)。
  939. assert f"\n涉及:{BUY_INVOLVED}\n" in body
  940. payload = build_payload(
  941. title=title,
  942. body=body,
  943. verdict=VERDICT_ERROR,
  944. timestamp="1700000000",
  945. sign="A" * 44,
  946. )
  947. assert len(json.dumps(payload).encode("utf-8")) <= limit
  948. for marker in range(4):
  949. token = f"标记{marker}"
  950. if token in body:
  951. seen.add(token)
  952. assert seen == {f"标记{index}" for index in range(4)}
  953. posted: list[bytes] = []
  954. def handler(request: httpx.Request) -> httpx.Response:
  955. posted.append(request.content)
  956. return httpx.Response(200, json={"code": 0})
  957. result = asyncio.run(
  958. send_card(
  959. _settings(),
  960. _card(tuple(_line(f"长错误{index}" + "错" * 800, VERDICT_ERROR) for index in range(6))),
  961. transport=_transport(handler),
  962. gate=_gate(),
  963. )
  964. )
  965. assert result.ok is True
  966. assert len(posted) >= 2
  967. for raw in posted:
  968. assert len(raw) <= 28 * 1024
  969. joined = "".join(item.decode("utf-8") for item in posted)
  970. for index in range(6):
  971. assert f"长错误{index}" in joined
  972. def _owner(
  973. kind: str,
  974. biz_key: str,
  975. verdict: str,
  976. lines: tuple[Line, ...] = (),
  977. ) -> SummaryOwner:
  978. return SummaryOwner(kind, biz_key, verdict, lines)
  979. def test_summary_lists_owners_by_verdict_and_kind() -> None:
  980. """概况按正确、错误、可疑三行写归属。主键按字典序,归属顺序固定,颜色取最严重的一档。"""
  981. owners = (
  982. _owner("fund_account", "FA2", VERDICT_ERROR),
  983. _owner("master_order", "ORD2", VERDICT_CORRECT),
  984. _owner("fund_account", "FA9", VERDICT_CORRECT),
  985. _owner("master_order", "ORD1", VERDICT_CORRECT),
  986. _owner("trs_user", "U9", VERDICT_SUSPECT),
  987. _owner("fund_account", "FA1", VERDICT_ERROR),
  988. )
  989. assert render_summary_title(CHAIN_TRADE, owners) == "链路-委托执行 正确3条,错误2条,可疑1条"
  990. assert render_summary_title(CHAIN_TRADE, owners, resend=True).endswith(" 重发")
  991. assert summary_verdict(owners) == VERDICT_ERROR
  992. assert render_summary_body(owners) == (
  993. "正确:主订单:ORD1、ORD2;资金账户:FA9\n"
  994. "\n"
  995. "错误:资金账户:FA1、FA2\n"
  996. "\n"
  997. "可疑:用户账号:U9"
  998. )
  999. def test_summary_writes_none_when_a_verdict_is_absent() -> None:
  1000. """没有的那一档写「无」,三行都在。没有结论行时不编示例。"""
  1001. owners = (_owner("master_order", "ORD1", VERDICT_CORRECT),)
  1002. body = render_summary_body(owners)
  1003. assert body == "正确:主订单:ORD1\n\n错误:无\n\n可疑:无"
  1004. assert "示例解读" not in body
  1005. assert render_summary_title(CHAIN_BUY, owners) == "链路-买入执行 正确1条,错误0条,可疑0条"
  1006. assert summary_verdict(owners) == VERDICT_CORRECT
  1007. def test_summary_ignores_detail_lines() -> None:
  1008. """归属上带了结论行也不写进概况。三档只列主键。"""
  1009. line = _line(
  1010. "总金额 = 1",
  1011. VERDICT_CORRECT,
  1012. calc="1.00 + 0.00 = 1.00;四舍五入",
  1013. check="总金额 = 1.00",
  1014. )
  1015. body = render_summary_body((_owner("master_order", "ORD1", VERDICT_CORRECT, (line,)),))
  1016. assert body == "正确:主订单:ORD1\n\n错误:无\n\n可疑:无"
  1017. assert "示例解读" not in body
  1018. assert "总金额" not in body
  1019. assert "依据" not in body
  1020. def test_summary_escapes_angle_brackets_in_keys() -> None:
  1021. """主键里的尖括号改成全角,避免 lark_md 当成标签。"""
  1022. body = render_summary_body(
  1023. (_owner("master_order", "<ORD>", VERDICT_ERROR, (_line("错公式", VERDICT_ERROR),)),)
  1024. )
  1025. assert body == "正确:无\n\n错误:主订单:<ORD>\n\n可疑:无"
  1026. assert "<ORD>" not in body
  1027. def test_summary_truncates_correct_names_before_errors() -> None:
  1028. """超长时先截正确名单,错误主键和「可疑:无」都留着。标题条数仍是原长。"""
  1029. owners = tuple(
  1030. _owner("master_order", f"K{index:020d}", VERDICT_CORRECT) for index in range(200)
  1031. ) + (_owner("master_order", "ERRKEY", VERDICT_ERROR),)
  1032. body = render_summary_body(owners)
  1033. correct, error, suspect = body.split("\n\n")
  1034. assert len(body) <= BODY_LIMIT
  1035. assert correct.startswith("正确:主订单:")
  1036. assert "…等共200个" in correct
  1037. assert error == "错误:主订单:ERRKEY"
  1038. assert suspect == "可疑:无"
  1039. assert render_summary_title(CHAIN_BUY, owners) == "链路-买入执行 正确200条,错误1条,可疑0条"
  1040. def test_summary_post_uses_chain_title_and_owner_body() -> None:
  1041. """概况请求是一张 lark_md。有错误时标题是红的,正文只列归属,不写营业日和示例。"""
  1042. seen: list[dict[str, object]] = []
  1043. def handler(request: httpx.Request) -> httpx.Response:
  1044. seen.append(json.loads(request.content.decode("utf-8")))
  1045. return httpx.Response(200, json={"code": 0})
  1046. card = OutboundCard(
  1047. card_kind=CARD_SUMMARY,
  1048. chain_name=CHAIN_BUY,
  1049. biz_key=CHAIN_BUY,
  1050. business_date=DAY,
  1051. trade_date=DAY,
  1052. batch_no="B001",
  1053. lines=(),
  1054. record_no="9",
  1055. owners=(
  1056. _owner("master_order", "ORD2", VERDICT_CORRECT, (_line("对公式", VERDICT_CORRECT),)),
  1057. _owner("master_order", "ORD1", VERDICT_ERROR, (_line("错公式", VERDICT_ERROR),)),
  1058. ),
  1059. )
  1060. result = asyncio.run(send_summary(_settings(), card, transport=_transport(handler), gate=_gate()))
  1061. assert result.ok is True
  1062. assert len(seen) == 1
  1063. header = seen[0]["card"]["header"]
  1064. assert header["template"] == "red"
  1065. assert header["title"]["content"] == "链路-买入执行 正确1条,错误1条,可疑0条"
  1066. text = seen[0]["card"]["elements"][0]["text"]["content"]
  1067. assert text == "正确:主订单:ORD2\n\n错误:主订单:ORD1\n\n可疑:无"
  1068. assert "对公式" not in text
  1069. assert "错公式" not in text
  1070. assert "营业日" not in text
  1071. def test_summary_without_owners_is_not_posted() -> None:
  1072. """没有归属就不发请求。"""
  1073. def handler(_request: httpx.Request) -> httpx.Response:
  1074. raise AssertionError("不应该发出请求")
  1075. card = OutboundCard(
  1076. card_kind=CARD_SUMMARY,
  1077. chain_name=CHAIN_BUY,
  1078. biz_key=CHAIN_BUY,
  1079. business_date=DAY,
  1080. trade_date=DAY,
  1081. batch_no="B001",
  1082. lines=(),
  1083. record_no="9",
  1084. )
  1085. result = asyncio.run(send_summary(_settings(), card, transport=_transport(handler), gate=_gate()))
  1086. assert result.attempted is False
  1087. assert result.ok is False
  1088. @dataclass(frozen=True)
  1089. class _DimLine:
  1090. """方向卡正文要读到的归属和报表编号。"""
  1091. formula_text: str
  1092. calc_text: str
  1093. calc_value: Decimal | None
  1094. check_text: str
  1095. check_value: Decimal | None
  1096. basis: str
  1097. verdict: str
  1098. report_code: str = ""
  1099. master_order_no: str = ""
  1100. fund_account: str = ""
  1101. user_account: str = ""
  1102. def _dim(
  1103. formula: str,
  1104. verdict: str,
  1105. *,
  1106. calc: str = "1 = 1",
  1107. check: str = "列 = 1",
  1108. calc_value: Decimal | None = Decimal("1"),
  1109. check_value: Decimal | None = Decimal("1"),
  1110. basis: str = "依据甲",
  1111. report_code: str = "",
  1112. master_order_no: str = "",
  1113. fund_account: str = "",
  1114. ) -> _DimLine:
  1115. return _DimLine(
  1116. formula_text=formula,
  1117. calc_text=calc,
  1118. calc_value=calc_value,
  1119. check_text=check,
  1120. check_value=check_value,
  1121. basis=basis,
  1122. verdict=verdict,
  1123. report_code=report_code,
  1124. master_order_no=master_order_no,
  1125. fund_account=fund_account,
  1126. )
  1127. def test_dimension_title_is_name_and_verdict() -> None:
  1128. """方向卡标题只写方向和结论。重发加在末尾。"""
  1129. assert render_title(VERDICT_SUSPECT, DIM_ORDER, CARD_DIMENSION, "2026-10-30") == "订单链路 可疑"
  1130. assert render_title(VERDICT_CORRECT, DIM_SALES, CARD_DIMENSION, "2026-10-30") == "销售报表 正确"
  1131. assert render_title(
  1132. VERDICT_ERROR, DIM_ORDER, CARD_DIMENSION, "2026-10-30", resend=True
  1133. ) == "订单链路 错误 重发"
  1134. def test_dimension_order_body_collapses_correct_fields() -> None:
  1135. """同一字段的正确行收成一块,并写上笔数。错误行单独展开,不并进这块。"""
  1136. body = render_body(
  1137. business_date=DAY,
  1138. trade_date=DAY,
  1139. batch_no="IB",
  1140. chain_name=DIM_ORDER,
  1141. record_no="8",
  1142. lines=(
  1143. _dim("票据面值", VERDICT_CORRECT, master_order_no="ORD1", calc="100 × 10% = 10"),
  1144. _dim("票据面值", VERDICT_CORRECT, master_order_no="ORD2", calc="100 × 10% = 10"),
  1145. _dim("订单卖出股数", VERDICT_SUSPECT, master_order_no="ORD9", calc_value=Decimal("3"), check_value=Decimal("9")),
  1146. _dim("关联报表索引:OTC003", VERDICT_CORRECT, calc="发行 OTC003:本轮主单 2 笔,正确 2。明细在发行报表。", check=""),
  1147. _dim("批次状态:发行", VERDICT_CORRECT, calc="发行门禁:无差异。明细在发行报表。", check="", calc_value=None, check_value=None),
  1148. ),
  1149. )
  1150. assert body.startswith("营业日:2026-10-15\n交易日:2026-10-15\n批次号:IB")
  1151. assert "涉及:" not in body
  1152. assert "人口:已完成或已结算的 ORD 主单。本方向正确 2 笔,错误 0 笔,可疑 1 笔。" in body
  1153. assert "发行门禁:无差异。明细在发行报表。" in body
  1154. assert "每单链路\n本轮没有执行事实或对冲执行根结论。" in body
  1155. assert "盘中公式\n字段:票据面值" in body
  1156. assert "清算对照\n本轮没有这类结论。" in body
  1157. assert "关联报表\n字段:发行 OTC003" in body
  1158. assert "结论:正确(2/2)" in body
  1159. assert "实据:ORD1、ORD2 无差异" in body
  1160. assert "实据:本轮主单 2 笔,正确 2" in body
  1161. assert "节点:订单卖出股数" in body
  1162. assert "结论:可疑。" in body
  1163. assert "错误清单:无" in body
  1164. assert "正确另有" not in body
  1165. def test_link_summary_describes_facts_and_hedge_roots() -> None:
  1166. """每单链路写成执行事实、对冲执行根两段,不把结论收成正确几笔。"""
  1167. body = render_body(
  1168. business_date=DAY,
  1169. trade_date=DAY,
  1170. batch_no="IB",
  1171. chain_name=DIM_ORDER,
  1172. record_no="13",
  1173. lines=(
  1174. _dim("执行事实列与源表一致", VERDICT_CORRECT, master_order_no="ORD1", calc="一致", calc_value=None, check_value=None),
  1175. _dim("执行事实列与源表一致", VERDICT_CORRECT, master_order_no="ORD2", calc="一致", calc_value=None, check_value=None),
  1176. _dim(
  1177. "对冲记录成交股数 = TRS 执行根的成交股数",
  1178. VERDICT_CORRECT,
  1179. master_order_no="ORD1",
  1180. calc="db_trs.trade_order.ext_ref=HR66 600",
  1181. calc_value=Decimal("600"),
  1182. check_value=Decimal("600"),
  1183. ),
  1184. _dim(
  1185. "对冲记录成交股数 = TRS 执行根的成交股数",
  1186. VERDICT_ERROR,
  1187. master_order_no="ORD2",
  1188. calc="db_trs.trade_order.ext_ref=HR70 没有行",
  1189. calc_value=None,
  1190. check_value=Decimal("100"),
  1191. ),
  1192. ),
  1193. )
  1194. assert "已完成或已结算的 ORD 主单 2 笔。执行事实核对 input 封存的成交名义本金、订单成交均价与实时源表是否一致:2 笔一致。" in body
  1195. assert "需要 TRS 执行根的对冲 2 条,成交股数与执行根对照:1 条一致。" in body
  1196. assert "自动单按 ext_ref(HR 加对冲主键)定位,补录按委托号或算法母单号。" in body
  1197. assert "自定义补录和从未建单不要求执行根。" in body
  1198. assert "ORD2:db_trs.trade_order.ext_ref=HR70 没有行" in body
  1199. assert "正确 8 笔,错误 0 笔,可疑 0 笔" not in body
  1200. def test_order_card_expands_clearing_and_related_reports() -> None:
  1201. """清算对照和关联报表按巡检稿的字段展开,不把多列收成一行笔数。"""
  1202. body = render_body(
  1203. business_date=DAY,
  1204. trade_date=DAY,
  1205. batch_no="IB",
  1206. chain_name=DIM_ORDER,
  1207. record_no="14",
  1208. lines=(
  1209. _dim(
  1210. "T日订单结算与工作快照一致",
  1211. VERDICT_CORRECT,
  1212. master_order_no="ORD1",
  1213. calc="一致",
  1214. calc_value=None,
  1215. check_value=None,
  1216. ),
  1217. _dim(
  1218. f"{CLEAR_MARK}交收台账",
  1219. VERDICT_CORRECT,
  1220. calc="字段:交收台账\n期望:交收推演\n实据:交收台账:本轮主单 8 笔,正确 8,错误 0,可疑 0。\n依据:02 方案交收\n结论:正确(8/8)",
  1221. calc_value=None,
  1222. check_value=None,
  1223. ),
  1224. _dim(
  1225. "关联报表索引:发行 OTC003",
  1226. VERDICT_CORRECT,
  1227. calc="字段:发行 OTC003\n期望:名义本金 = 执行名义\n实据:发行 OTC003:本轮主单 8 笔,正确 8,错误 0,可疑 0。\n依据:产品原型 OTC003\n结论:正确(8/8)",
  1228. calc_value=None,
  1229. check_value=None,
  1230. ),
  1231. ),
  1232. )
  1233. assert "清算对照\n字段:T日订单结算与工作快照" in body
  1234. assert "字段:交收台账\n期望:交收推演" in body
  1235. assert "关联报表\n字段:发行 OTC003\n期望:名义本金 = 执行名义" in body
  1236. def test_dimension_report_body_keeps_correct_rows_on_error_theme() -> None:
  1237. """报表卡即使总结论是错误,正确列仍留在分报表,错误行逐条写期望和实据。"""
  1238. body = render_body(
  1239. business_date=DAY,
  1240. trade_date=DAY,
  1241. batch_no="SB",
  1242. chain_name=DIM_SALES,
  1243. record_no="9",
  1244. theme=VERDICT_ERROR,
  1245. lines=(
  1246. _dim(
  1247. "现金余额",
  1248. VERDICT_CORRECT,
  1249. check="OTC001 资金账户资金报表.现金余额 = 1",
  1250. report_code="OTC001",
  1251. fund_account="FA1",
  1252. ),
  1253. _dim(
  1254. "买入待交收流水金额 = −订单总金额",
  1255. VERDICT_ERROR,
  1256. calc="−220421.76",
  1257. check="OTC002 现金变动记录.金额 = -220400.00(资金账户 FA00000001)",
  1258. calc_value=Decimal("-220421.76"),
  1259. check_value=Decimal("-220400.00"),
  1260. report_code="OTC002",
  1261. fund_account="FA00000001",
  1262. master_order_no="ORD001",
  1263. basis="买入订单第 28 行",
  1264. ),
  1265. ),
  1266. )
  1267. assert "报表:OTC001 资金账户资金报表" in body
  1268. assert "筛选:营业日 2026-10-15,资金账户 FA1" in body
  1269. assert "期望:现金余额" in body
  1270. assert "实据:1 列无差异" in body
  1271. assert "结论:正确" in body
  1272. assert "正确与可疑留在核对明细" not in body
  1273. assert "节点:买入待交收流水金额 = −订单总金额" in body
  1274. assert "期望:−220421.76" in body
  1275. assert "当前 -220400.00" in body or "当前 -220400" in body
  1276. assert "不符合预期" in body
  1277. assert "筛选:主订单 ORD001 · 资金账户 FA00000001" in body
  1278. def test_report_bases_are_one_per_line() -> None:
  1279. """多条依据各占一行。分号拼在一起时,原型路径会挤成一行。"""
  1280. body = render_body(
  1281. business_date=DAY,
  1282. trade_date=DAY,
  1283. batch_no="IB",
  1284. chain_name=DIM_SALES,
  1285. record_no="12",
  1286. lines=(
  1287. _dim(
  1288. "股票数量",
  1289. VERDICT_CORRECT,
  1290. check="OTC003 共赢票据订单报表.股票数量 = 1",
  1291. report_code="OTC003",
  1292. basis="产品原型 订单报表.md 「股票数量」",
  1293. ),
  1294. _dim(
  1295. "价格",
  1296. VERDICT_CORRECT,
  1297. check="OTC003 共赢票据订单报表.价格 = 1",
  1298. report_code="OTC003",
  1299. basis="产品原型 订单报表.md 「价格」",
  1300. ),
  1301. _dim(
  1302. "总金额",
  1303. VERDICT_CORRECT,
  1304. check="OTC003 共赢票据订单报表.总金额 = 1",
  1305. report_code="OTC003",
  1306. basis="产品原型 订单报表.md 「总金额」",
  1307. ),
  1308. ),
  1309. )
  1310. assert "依据:产品原型 订单报表.md 「股票数量」\n产品原型 订单报表.md 「价格」\n产品原型 订单报表.md 「总金额」" in body
  1311. assert "另有" not in body
  1312. assert "依据:产品原型 订单报表.md 「股票数量」;" not in body
  1313. assert "期望:股票数量\n价格\n总金额" in body
  1314. def test_dimension_long_body_keeps_fields_and_errors() -> None:
  1315. """方向卡不按 3500 字删掉盘中公式。错误行和字段都留在正文里。"""
  1316. correct = tuple(
  1317. _dim(f"字段{index}", VERDICT_CORRECT, basis="依" * 80, master_order_no=f"ORD{index}")
  1318. for index in range(40)
  1319. )
  1320. error = _dim("金额不符", VERDICT_ERROR, calc_value=Decimal("1"), check_value=Decimal("2"), master_order_no="ORDX")
  1321. body = render_body(
  1322. business_date=DAY,
  1323. trade_date=DAY,
  1324. batch_no="IB",
  1325. chain_name=DIM_ORDER,
  1326. record_no="11",
  1327. lines=(*correct, error),
  1328. )
  1329. assert "字段:字段0" in body
  1330. assert "字段:字段39" in body
  1331. assert "节点:金额不符" in body
  1332. assert "不符合预期" in body
  1333. assert "正确另有" not in body
  1334. assert len(body) > BODY_LIMIT