bill_service.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. // 学生账单查询服务(sp_user_expense_get_bill → BillService.GetBill)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md
  4. // - §3.4 #25 `sp_user_expense_get_bill`(dump **764094~764238**;4 张 MEMORY 临时表 → 内存结构)
  5. // - §3.8-**E2**(`create_time` 属**业务时间**,参与对账、逐值 0 容差)
  6. // - §3.8-**A18**(`type=108` 双来源:**只归因、不裁剪**——本服务的余额类汇总一律全类型计入)
  7. // - §5.1 纪律 4「横切优先」(账单恒等式 + 期初/期末不随分页变化)
  8. //
  9. // ---------------------------------------------------------------------------
  10. // dump 骨架(764116~764198,**按原文,不做"优化"**)
  11. // ---------------------------------------------------------------------------
  12. //
  13. // CREATE TEMPORARY TABLE temp_user_expense_log(type INT, logid VARCHAR(50), money DECIMAL(10,3), create_time VARCHAR(100));
  14. // INSERT INTO temp_user_expense_log SELECT type,logid,money,create_time FROM t_user_expense_log WHERE userid=inUserid;
  15. // -- 同法 temp_courses_activity_signed / temp_courses_activity_used_log / temp_user_balance_request
  16. //
  17. // 764173 SELECT SUM(money) FROM temp_user_expense_log WHERE type=107 AND create_time BETWEEN inStartTime AND inEndTime; -- 当期推荐额
  18. // 764175 SELECT SUM(money) FROM temp_user_expense_log WHERE type in(101,201,104,204,105,205) AND create_time BETWEEN ...; -- 当期消费
  19. // 764177 SELECT SUM(money) FROM temp_user_expense_log as log1 WHERE create_time<inEndTime; -- **期末余额**
  20. // 764179 SELECT SUM(money) FROM temp_user_expense_log as log2 WHERE create_time<inStartTime; -- **期初余额**
  21. // 764182-764189 赠送额结余(当前 764182-764184 / 上一次 764187-764189)
  22. // ⚠ 其中 `SUM(...) OVER signed RIGHT JOIN (... GROUP BY activityid)` 存在**扇出缺陷**,
  23. // 已登记 §3.8-A21,Go **刻意不复刻**(详见 giveBalanceBefore 注释第 3 条)
  24. // → 764193 当期使用赠送 = 当期赠送 + 上期结余 − 当前结余(`SET currentUsedGiveCost = ...`)
  25. //
  26. // ---------------------------------------------------------------------------
  27. // ★ 三种比较语义并存(**dump 的既有不对称,禁止统一**)
  28. // ---------------------------------------------------------------------------
  29. //
  30. // 「当期发生额」= `BETWEEN inStartTime AND inEndTime` → **两端闭**:start <= t <= end
  31. // 「期末余额」 = `create_time < inEndTime` → **不含右端**:t < end
  32. // 「期初余额」 = `create_time < inStartTime` → **不含右端**:t < start
  33. //
  34. // ⇒ 因此 **期末 − 期初 = Σ(start <= t < end)**,而**当期发生额 = Σ(start <= t <= end)**,
  35. // 二者在 `t = inEndTime` 处**恰好相差落在该时刻的那些流水**。
  36. // 这不是 bug,是 dump 把 `create_time` 当 `VARCHAR(100)` 直接字符串比较留下的历史口径:
  37. // 用户在 `end` 当天/当时刻的流水会进"当期发生额",但不进"期末余额"。
  38. // 本服务把这个差额**显式暴露**为 `FlowInclusiveEnd − FlowExclusiveEnd`(`BillSummary` 两字段),
  39. // 让调用方/对账脚本能看见它,而不是靠"统一成一种语义"把它抹掉(抹掉就与旧系统数字不一致)。
  40. //
  41. // ---------------------------------------------------------------------------
  42. // ★ 入参时间边界(前端必须知道的一句话)
  43. // ---------------------------------------------------------------------------
  44. //
  45. // `from` 是**含**下界(>= from),`to` 是**不含**上界(< to)—— 对"余额/期初期末"而言。
  46. // **若前端要"含当日",必须传次日或 `23:59:59`**(例如查 2026-03-01 当日流水,应传
  47. // from=2026-03-01T00:00:00, to=2026-03-02T00:00:00;传 to=2026-03-01T00:00:00 会得到该日 0 条)。
  48. // dump 用 `VARCHAR` 做 `BETWEEN`,语义随字符串格式漂移;目标态改用 `datetime(3)` 后,
  49. // **入参语义按上述定死**(这是"目标态显式化",不是行为漂移:旧系统前端一直传 `YYYY-MM-DD`,
  50. // dump 里 `BETWEEN '2026-03-01' AND '2026-03-31'` 同样不含 03-31 的时分秒部分)。
  51. //
  52. // ---------------------------------------------------------------------------
  53. // ★ `campusIds` 入参的真相(dump 里是**死参数**)
  54. // ---------------------------------------------------------------------------
  55. //
  56. // dump 764094 声明了 `IN inCampusids VARCHAR(1000)`,但 764095~764237 的**整段过程体一次都没引用它**
  57. // (在 dump 里 grep 结果只有声明行)。因此本服务的 `CampusIDs` **只做解析/回显,不做过滤**——
  58. // 与 dump 完全一致。绝不为此给 `t_user_expense_log` 加 campus 维度(§3.8-f 约束 2:
  59. // 流水表无 campus 列,账单恒等式只能按 user 做);若产品确需"校区视图",须另开"按
  60. // t_courses_student / t_user_expense_school 的 join 路径"需求,不能在这里偷偷过滤。
  61. package service
  62. import (
  63. "context"
  64. "strconv"
  65. "strings"
  66. "time"
  67. "gorm.io/gorm"
  68. "yuansiwei/unified-backend/internal/academic/model"
  69. "yuansiwei/unified-backend/internal/academic/money"
  70. "yuansiwei/unified-backend/internal/academic/port"
  71. "yuansiwei/unified-backend/internal/academic/repository"
  72. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  73. appscope "yuansiwei/unified-backend/internal/infra/scope"
  74. )
  75. // 账单汇总用到的流水类型集合(dump 764175 原文 `type in(101,201,104,204,105,205)`)。
  76. var (
  77. // billSpendTypes 「当期消费」类型集合(逐值与 dump 764175 的 IN 列表一致)。
  78. billSpendTypes = []int32{
  79. model.ExpenseLogTypeCourseCampus, // 101
  80. model.ExpenseLogTypeCourseCampusB, // 201
  81. model.ExpenseLogTypeCourseSchoolA, // 104
  82. model.ExpenseLogTypeCourseSchoolC, // 204
  83. model.ExpenseLogTypeCourseSchoolB, // 105
  84. model.ExpenseLogTypeCourseSchoolD, // 205
  85. }
  86. )
  87. // BillService 学生账单查询服务(只读;无事务)。
  88. //
  89. // 字段:
  90. // - repo 账单仓储(4 张来源表按 user 全量取回);
  91. // - member 成员校验器(可 nil;非 nil 时对"学生是否属于当前机构/校区"做越权预检);
  92. // - sc 请求作用域(可 nil:系统任务/双跑脚本视角)。
  93. type BillService struct {
  94. repo *repository.BillRepo
  95. member port.MembershipChecker
  96. sc *appscope.Scope
  97. }
  98. // NewBillService 构造账单服务。
  99. //
  100. // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域;系统任务可 nil)。
  101. // 出参:*BillService(成员校验需 `WithMembershipChecker` 注入后生效)。
  102. func NewBillService(db *gorm.DB, sc *appscope.Scope) *BillService {
  103. return &BillService{repo: repository.NewBillRepo(db), sc: sc}
  104. }
  105. // WithMembershipChecker 注入成员校验器(返自身,便于链式装配)。
  106. //
  107. // 入参:member port.MembershipChecker(nil 表示不做越权预检——仅限双跑/回填脚本)。
  108. // 出参:*BillService(同一实例)。
  109. func (s *BillService) WithMembershipChecker(member port.MembershipChecker) *BillService {
  110. s.member = member
  111. return s
  112. }
  113. // GetBillInput 账单查询入参(对齐 dump 的 4 个入参 + 明细分页)。
  114. //
  115. // 字段:
  116. // - UserID 学生(ULID;必填);
  117. // - CampusIDs 校区逗号串(dump 第二入参 `inCampusids`;**只解析回显、不过滤**,见文件头);
  118. // - From 起始时间(**含**下界);
  119. // - To 结束时间(**不含**上界——"期末"口径;「当期发生额」按 dump 的 BETWEEN 含右端);
  120. // - Page / PageSize 明细分页(<=0 由 ClampPage 归一;PageSize 上限 200)。
  121. type GetBillInput struct {
  122. // UserID 学生(canonical_user_id / ULID 26 位)。
  123. UserID string
  124. // CampusIDs 校区逗号串(如 "1,2,3";"" 或不传 = 不加过滤;**dump 未使用,仅回显**)。
  125. CampusIDs string
  126. // From 起始时间(含)。
  127. From time.Time
  128. // To 结束时间(不含)。
  129. To time.Time
  130. // Page 页码(1 基)。
  131. Page int
  132. // PageSize 每页条数。
  133. PageSize int
  134. }
  135. // BillSummary 账单汇总(dump 764228-764236 的 9 个 SELECT 列 + 3 个对账辅助列)。
  136. //
  137. // 全部金额为 `money.Amount`(分,定点),JSON 输出为"两位小数字符串"(如 "123.45")——
  138. // 与旧库 `DECIMAL(10,3)` 的显示习惯对齐到 2 位(目标态 DDL 为 `decimal(12,2)`)。
  139. type BillSummary struct {
  140. // PrevBalance 期初余额 = Σ(money WHERE create_time < From)(dump 764179 `previousBalance`)。
  141. PrevBalance money.Amount `json:"prevBalance"`
  142. // EndBalance 期末余额 = Σ(money WHERE create_time < To)(dump 764177 `currentBalance`)。
  143. EndBalance money.Amount `json:"endBalance"`
  144. // Recharge 当期充值 = Σ(money WHERE type=100 AND From <= create_time <= To)(dump 764116 `DECLARE rechargeCur CURSOR ...`,764159 `OPEN`)。
  145. Recharge money.Amount `json:"recharge"`
  146. // Gift 当期赠送额 = Σ(signed.activity_money)(经"充值工单 × 报名"关联,dump 764116 游标第二列 `SUM(activity.activity_money)`)。
  147. Gift money.Amount `json:"gift"`
  148. // RecommendAmount 当期老带新推荐额 = Σ(money WHERE type=107 AND 区间内)(dump 764173)。
  149. RecommendAmount money.Amount `json:"recommendAmount"`
  150. // Spend 当期消费 = Σ(money WHERE type IN (101,201,104,204,105,205) AND 区间内)(dump 764175)。
  151. Spend money.Amount `json:"spend"`
  152. // GiveUsed 当期使用赠送额(dump 764193 `currentUsedGiveCost` = Gift + GivePrevBalance − GiveEndBalance)。
  153. GiveUsed money.Amount `json:"giveUsed"`
  154. // GivePrevBalance 期初赠送结余(dump 764187-764189 `previousGiveCost`;口径见 giveBalanceBefore)。
  155. GivePrevBalance money.Amount `json:"givePrevBalance"`
  156. // GiveEndBalance 期末赠送结余(dump 764182-764184 `currentGiveCost`;口径同上)。
  157. GiveEndBalance money.Amount `json:"giveEndBalance"`
  158. // FlowExclusiveEnd 窗口内全类型流水合计(From <= t **<** To)——
  159. // **恒等于 EndBalance − PrevBalance**(§5.1 第 4 条:账单恒等式的可判定形式)。
  160. FlowExclusiveEnd money.Amount `json:"flowExclusiveEnd"`
  161. // FlowInclusiveEnd 窗口内全类型流水合计(From <= t **<** To 的**次日零点**)——
  162. // 即真正"含 `to` 当天",与 dump 的 `BETWEEN` 口径对齐(`to` 为纯日期时的等价形式)。
  163. //
  164. // 二者之差 == 落在 `to` **当天**的流水。⚠ 不是"恰好等于 `to` 那一瞬"的流水 ——
  165. // 后者的判据是 `t <= To`,那正是台账 F31 的缺陷形态:当天 0 点后所有流水全部漏算。
  166. FlowInclusiveEnd money.Amount `json:"flowInclusiveEnd"`
  167. // CampusIDs dump 第二入参的解析结果(**仅回显**:dump 全程未引用该参数,本服务不过滤)。
  168. CampusIDs []int64 `json:"campusIds"`
  169. }
  170. // BillDetail 账单明细行(分页;按 `create_time DESC, id DESC`)。
  171. type BillDetail struct {
  172. // ID 流水 ID(同一时刻多行的稳定排序键)。
  173. ID int64 `json:"id"`
  174. // Type 流水类型(model.ExpenseLogType*)。
  175. Type int32 `json:"type"`
  176. // TypeTitle 类型中文名(只用于展示;未登记类型返回 "其它")。
  177. TypeTitle string `json:"typeTitle"`
  178. // LogID 业务单号(原 logid;可为空串)。
  179. LogID string `json:"logId"`
  180. // Money 变更金额(元;符号已表方向,见 model.ExpenseLogType* 注释)。
  181. Money money.Amount `json:"money"`
  182. // CreateTime 业务时间(§3.8-E2 ①类,参与对账、逐值 0 容差)。
  183. CreateTime time.Time `json:"createTime"`
  184. }
  185. // BillResult 账单查询结果(汇总 + 明细分页两段,设计 §3.4 #25「两段返回」)。
  186. type BillResult struct {
  187. // Summary 汇总(**不受分页影响**)。
  188. Summary BillSummary `json:"summary"`
  189. // Details 明细(当前页)。
  190. Details []BillDetail `json:"details"`
  191. // Total 明细总条数(分页总数)。
  192. Total int64 `json:"total"`
  193. // Page 当前页码(已钳制)。
  194. Page int `json:"page"`
  195. // PageSize 每页条数(已钳制,∈[1,200])。
  196. PageSize int `json:"pageSize"`
  197. // Pages 总页数 = ceil(Total / PageSize)(分页契约要求五字段齐全:list/total/page/pageSize/pages)。
  198. //
  199. // 算法纪律(**别用 `math.Ceil(float64)`**):整数向上取整 `(Total + PageSize - 1) / PageSize`;
  200. // `PageSize <= 0` 时恒 `0`(防除零——虽然 PageSize 已被 ClampPage 钳到 ≥1,这道守卫是给
  201. // "将来有人绕过 Service 直接构造 BillResult" 用的)。
  202. // 入参用**钳制后**的 PageSize 与**窗口内**的 Total,不是请求原始值。
  203. Pages int `json:"pages"`
  204. // From / To 回显查询窗口(审计用;语义见 GetBillInput)。
  205. From time.Time `json:"from"`
  206. To time.Time `json:"to"`
  207. }
  208. // GetBill 查询学生账单(汇总 + 明细分页)。
  209. //
  210. // 入参:
  211. // - ctx 请求上下文;
  212. // - in GetBillInput(UserID 必填;From/To 必填且 From <= To)。
  213. //
  214. // 出参:
  215. // - *BillResult:汇总 + 明细;明细为空时 Details 为长度 0 的非 nil 切片;
  216. // - ErrBadParam(2001) 缺参/时间窗非法;ErrUnscoped(1004) 越权(注入了 MembershipChecker 时);
  217. // ErrDBError(5002) 数据库异常或金额字面量解析失败。
  218. //
  219. // **明细口径(v1.4 口径更正)**:`Details` 是**查询窗口内的流水**,条件与 `FlowExclusiveEnd`
  220. // **完全一致**(`from <= create_time < to`,左闭右开),按 `create_time DESC, id DESC` 分页;
  221. // `Total` 用**逐字相同**的 WHERE 统计(同快照,不二次查)。
  222. //
  223. // 为什么改(原实现"明细不过滤、汇总按窗口"是错的):那会让同一响应里出现两个作用域——
  224. // `summary` 是 3 月的、明细却是全部历史、`total` 是历史总数。前端选"3 月账单"翻到的是全量流水,
  225. // 属真 bug 级体验问题;而"明细与汇总口径不一致"恰恰是 §5.1 纪律 4 要防的那类"各比各的"。
  226. // dump 没有明细列表 ⇒ 这块**无 dump 约束**,可按上述自洽口径自由设计。
  227. // 明细与汇总的口径差**只有一处**且是显式的:落在 `t = to` 的流水进 `FlowInclusiveEnd`(含右端),
  228. // 但不进明细(右开)——前端口径表已写明,`flowInclusiveEnd` 就是给它看的。
  229. //
  230. // 只读:本方法**不开事务**(dump 的 4 张 MEMORY 临时表是一次性快照,等价于"同一读视图";
  231. // 目标态在单连接上顺序执行 4 次查询,落库并发写入造成的瞬间不一致由"流水只增不改"兜底)。
  232. func (s *BillService) GetBill(ctx context.Context, in GetBillInput) (*BillResult, error) {
  233. if strings.TrimSpace(in.UserID) == "" {
  234. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: GetBill 需要 user_id"))
  235. }
  236. if in.From.IsZero() || in.To.IsZero() {
  237. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  238. errString("service: GetBill 需要 from/to(账户区间对账的两个边界都是必填)"))
  239. }
  240. if in.From.After(in.To) {
  241. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  242. errString("service: GetBill 的 from 不能晚于 to"))
  243. }
  244. campusIDs, err := parseCampusIDs(in.CampusIDs)
  245. if err != nil {
  246. return nil, err
  247. }
  248. // 越权预检:4 张来源表都无作用域列(或跨校区可见),必须在这里断"该学生属于当前机构/校区"
  249. if s.member != nil && s.sc != nil {
  250. if err := s.member.AssertInScope(ctx, in.UserID, s.sc.InstitutionID, s.sc.CampusID); err != nil {
  251. return nil, err
  252. }
  253. }
  254. logs, err := s.repo.LoadLogs(ctx, in.UserID)
  255. if err != nil {
  256. return nil, err
  257. }
  258. // 充值工单**只在"赠送额"这条链上用到**(dump 764116-764121 游标的 LEFT JOIN),BETWEEN 含两端。
  259. reqNos, err := s.repo.LoadRechargeRequestNos(ctx, in.UserID, in.From, in.To)
  260. if err != nil {
  261. return nil, err
  262. }
  263. signed, err := s.repo.LoadActivitySigned(ctx, in.UserID)
  264. if err != nil {
  265. return nil, err
  266. }
  267. used, err := s.repo.LoadActivityUsedLogs(ctx, in.UserID)
  268. if err != nil {
  269. return nil, err
  270. }
  271. summary, err := buildBillSummary(logs, reqNos, signed, used, in.From, in.To)
  272. if err != nil {
  273. return nil, err
  274. }
  275. summary.CampusIDs = campusIDs
  276. page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
  277. pageNo, pageSize := page.Normalize()
  278. // 明细与汇总同窗口:`from <= create_time < to`(右开,与 FlowExclusiveEnd 同口径);
  279. // total 与 list 用**逐字相同**的 WHERE(同快照,不二次查)。
  280. total, err := s.repo.CountLogs(ctx, in.UserID, in.From, in.To)
  281. if err != nil {
  282. return nil, err
  283. }
  284. rows, err := s.repo.PageLogs(ctx, in.UserID, in.From, in.To, page)
  285. if err != nil {
  286. return nil, err
  287. }
  288. details := make([]BillDetail, 0, len(rows))
  289. for i := range rows {
  290. amount, perr := money.Parse(rows[i].Money)
  291. if perr != nil {
  292. return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  293. }
  294. logID := ""
  295. if rows[i].LogID != nil {
  296. logID = *rows[i].LogID
  297. }
  298. details = append(details, BillDetail{
  299. ID: rows[i].ID,
  300. Type: rows[i].Type,
  301. TypeTitle: billTypeTitle(rows[i].Type),
  302. LogID: logID,
  303. Money: amount,
  304. CreateTime: rows[i].CreateTime,
  305. })
  306. }
  307. return &BillResult{
  308. Summary: summary,
  309. Details: details,
  310. Total: total,
  311. Page: pageNo,
  312. PageSize: pageSize,
  313. Pages: billPages(total, pageSize),
  314. From: in.From,
  315. To: in.To,
  316. }, nil
  317. }
  318. // billPages 计算总页数(整数向上取整;纯函数,便于边界单测)。
  319. //
  320. // 入参:total 窗口内流水条数(已按查询窗口统计);pageSize 每页条数(**已钳制**,通常 ∈[1,200])。
  321. // 出参:总页数;`pageSize <= 0` 恒 0(防除零)。
  322. //
  323. // 边界(两个最容易写错的地方,用例逐个钉住):
  324. // - `total = 0` → **0** 页(不是 1 页:空结果集不该显示"第 1/1 页");
  325. // - `total = 25, pageSize = 10` → **3** 页(向上取整;用整数运算 `(25+10-1)/10`,
  326. // **不要** `math.Ceil(2.5)` 再转 int——浮点在 1e16 量级的 total 上会丢精度,且多一条 import)。
  327. func billPages(total int64, pageSize int) int {
  328. if pageSize <= 0 {
  329. return 0
  330. }
  331. if total <= 0 {
  332. return 0
  333. }
  334. size := int64(pageSize)
  335. return int((total + size - 1) / size)
  336. }
  337. // buildBillSummary 由 4 份内存结构算出 dump 的 9 个汇总列 + 3 个对账辅助列(纯函数,便于单测)。
  338. //
  339. // 入参:
  340. // - logs 该 user 全量流水(LoadLogs);
  341. // - reqNos 区间内已通过的普通充值工单 biz_no 列表(LoadRechargeRequestNos,BETWEEN 含两端);
  342. // - signed 该 user 全部活动报名行(LoadActivitySigned);
  343. // - used 该 user 全部老带新获得/使用记录(LoadActivityUsedLogs);
  344. // - from/to 查询窗口(from 含、to 不含;「当期发生额」用 BETWEEN 含 to)。
  345. //
  346. // 出参:BillSummary;金额字面量非法 → ErrDBError。
  347. //
  348. // 累加纪律:**全部在 Go 侧用 money.Amount 定点累加**(禁止 SQL SUM(decimal),见 BillRepo 头注释)。
  349. func buildBillSummary(
  350. logs []model.UserExpenseLog,
  351. reqNos []string,
  352. signed []model.CoursesActivitySigned,
  353. used []model.CoursesActivityUsedLog,
  354. from, to time.Time,
  355. ) (BillSummary, error) {
  356. var out BillSummary
  357. // inclusiveEnd = `to` **所在日期的次日零点** —— 「含 `to` 当天」的真正上界(右开)。
  358. //
  359. // 为什么不能直接写 `!t.After(to)`(旧实现):`to` 是**日粒度**入参(`from/to` 只传日期时,
  360. // handler 解析成当日 00:00),`<= to` 只能命中"恰好午夜那一瞬"的流水 ⇒ 当天绝大多数流水都进不来,
  361. // 「含当天」实际不成立(台账 F30/F31)。旧版之所以"看起来对",是因为它把日期解析成 **UTC 零点**
  362. // ⇒ 边界落在本地 08:00,恰好把当天 0~8 点的流水带了进来 —— 一个 bug 掩盖了另一个 bug。
  363. //
  364. // 语义边界:本列只负责**展示口径**(前端"含最后一天"的那个数),不影响 `EndBalance` /
  365. // `FlowExclusiveEnd`(两者仍是 `< to`,与 legacy 的 `< inEndTime` 对齐)。
  366. inclusiveEnd := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, to.Location()).AddDate(0, 0, 1)
  367. if to.IsZero() {
  368. inclusiveEnd = to
  369. }
  370. // dump 764116 游标:充值工单参与集合(`temp_user_balance_request`,764128 装入)
  371. rechargeReqNos := make(map[string]struct{}, len(reqNos))
  372. for _, no := range reqNos {
  373. if no == "" {
  374. continue // 空串不参与匹配(旧库 requestid 可为空串,误配会凭空造出赠送额)
  375. }
  376. rechargeReqNos[no] = struct{}{}
  377. }
  378. // 实际参与赠送额汇总的工单号:dump 的 join 路径是 `log.logid = request.id`,
  379. // 因此**还要有一条区间内的 type=100 流水**指向该工单,才可能把 signed.activity_money 计进来。
  380. matchedReqNos := make(map[string]struct{}, len(rechargeReqNos))
  381. for i := range logs {
  382. row := logs[i]
  383. amount, err := money.Parse(row.Money)
  384. if err != nil {
  385. return BillSummary{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
  386. }
  387. // 期初 / 期末:`create_time < 边界`(不含右端,dump 764177 / 764179)
  388. if row.CreateTime.Before(from) {
  389. out.PrevBalance = out.PrevBalance.Add(amount)
  390. }
  391. if row.CreateTime.Before(to) {
  392. out.EndBalance = out.EndBalance.Add(amount)
  393. }
  394. // 窗口内合计:两个口径各算一次(差额 == 落在 t = to 的流水,见 BillSummary 字段注释)
  395. if !row.CreateTime.Before(from) {
  396. if row.CreateTime.Before(to) {
  397. out.FlowExclusiveEnd = out.FlowExclusiveEnd.Add(amount)
  398. }
  399. if row.CreateTime.Before(inclusiveEnd) {
  400. out.FlowInclusiveEnd = out.FlowInclusiveEnd.Add(amount)
  401. }
  402. }
  403. // 三类「当期发生额」:BETWEEN = 两端闭(dump 764173 / 764175 / 764116)
  404. if row.CreateTime.Before(from) || row.CreateTime.After(to) {
  405. continue
  406. }
  407. switch {
  408. case row.Type == model.ExpenseLogTypeRecharge:
  409. out.Recharge = out.Recharge.Add(amount)
  410. if row.LogID != nil {
  411. if _, ok := rechargeReqNos[*row.LogID]; ok {
  412. matchedReqNos[*row.LogID] = struct{}{}
  413. }
  414. }
  415. case row.Type == model.ExpenseLogTypeRecommend:
  416. out.RecommendAmount = out.RecommendAmount.Add(amount)
  417. case isBillSpendType(row.Type):
  418. out.Spend = out.Spend.Add(amount)
  419. }
  420. }
  421. // 当期赠送额:Σ(signed.activity_money) where signed.request_no ∈ matchedReqNos
  422. signedByActivity := make(map[int64]money.Amount, len(signed))
  423. for i := range signed {
  424. amount, err := money.Parse(signed[i].ActivityMoney)
  425. if err != nil {
  426. return BillSummary{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
  427. }
  428. signedByActivity[signed[i].ActivityID] = signedByActivity[signed[i].ActivityID].Add(amount)
  429. if signed[i].RequestNo == nil {
  430. continue
  431. }
  432. if _, ok := matchedReqNos[*signed[i].RequestNo]; ok {
  433. out.Gift = out.Gift.Add(amount)
  434. }
  435. }
  436. // 赠送结余(dump 764182-764189):RIGHT JOIN 使"**只有出现过 used_log 的活动**"才进入汇总,
  437. // 且 used_log 的金额是**原值相加**(老带新结算写负值,重放写正值)——逐字复刻,不做符号归一。
  438. giveEnd, err := giveBalanceBefore(used, signedByActivity, to)
  439. if err != nil {
  440. return BillSummary{}, err
  441. }
  442. givePrev, err := giveBalanceBefore(used, signedByActivity, from)
  443. if err != nil {
  444. return BillSummary{}, err
  445. }
  446. out.GiveEndBalance = giveEnd
  447. out.GivePrevBalance = givePrev
  448. // dump 764193:`SET currentUsedGiveCost = currentTotalActivityMoney + previousGiveCost - currentGiveCost`
  449. out.GiveUsed = out.Gift.Add(givePrev).Sub(giveEnd)
  450. return out, nil
  451. }
  452. // giveBalanceBefore 复刻 dump 764182-764189 的"某个 cutoff 之前的赠送金额结余"。
  453. //
  454. // dump 原文(原文嵌套三层;这里按语义等价拆解,**箭头右侧是本函数的实现**):
  455. //
  456. // SELECT SUM(activity_money) , SUM(activity_money_used)
  457. // FROM ( SELECT signed.activity_money, usedLog.activity_money_used
  458. // FROM temp_courses_activity_signed signed
  459. // RIGHT JOIN ( SELECT activityid, SUM(activity_money_used) AS activity_money_used
  460. // FROM temp_courses_activity_used_log
  461. // WHERE create_time < cutoff GROUP BY activityid ) usedLog
  462. // ON signed.activityid = usedLog.activityid ) t1
  463. // → 结果 = Σ(该 user 在"有 used_log(< cutoff)的活动"上的全部 signed.activity_money)
  464. // + Σ(used_log.activity_money_used where create_time < cutoff)
  465. //
  466. // **RIGHT JOIN 的三个后果**(前两条**复刻**、第三条**刻意不复刻** —— 别把三条一视同仁):
  467. // 1. 某活动若从未产生 used_log(< cutoff),其 signed.activity_money **完全不计入**结余;
  468. // —— **复刻**(dump 的语义事实,不是缺陷)。
  469. // 2. 某活动只有 used_log、没有 signed 行时,signed 侧为 NULL,SUM 按 0 计(不报错);
  470. // —— **复刻**(同上)。
  471. // 3. 同一活动有 **k 条 signed** 时,该活动的 `activity_money_used` 汇总会被**重复计入 k 次**
  472. // (`SUM` 建在 `signed RIGHT JOIN (… GROUP BY activityid)` 上 ⇒ SQL 扇出 / fan-out)。
  473. // —— **刻意不复刻**(设计 §3.8-**A21**,归因 `DUMP-BUGFIX`):本函数按 `activity_id` 先去重、
  474. // 每个活动的 signed 额与 used 额**各只计一次**。
  475. // 真实数据**仅 1 例**(`user 32596 / activity 1`,两条各 `1000.000`);cutoff `2020-01-01` 时
  476. // dump 得 `737.022`、Go 得 `1737.022`,**差 1000.000**;跨该活动的窗口 `GiveUsed` 同样差。
  477. // **⚠ 不要"照抄"第 3 条** —— 那是 dump 的缺陷;补回去等于主动引入 A21 已排除的错误。
  478. // 用例:`TestGetBillGiveBalanceDeduplicatesSignedRows`(造"同活动 2 条 signed",断言只计一次)。
  479. //
  480. // 入参:
  481. // - used 全量 used_log;
  482. // - signedByActivity 按 activity_id 汇总的 signed.activity_money(**无时间过滤**);
  483. // - cutoff 截止时间(严格小于:`create_time < cutoff`)。
  484. //
  485. // 出参:money.Amount 结余(可为负);金额字面量非法 → ErrDBError。
  486. func giveBalanceBefore(
  487. used []model.CoursesActivityUsedLog,
  488. signedByActivity map[int64]money.Amount,
  489. cutoff time.Time,
  490. ) (money.Amount, error) {
  491. total := money.Amount(0)
  492. activityHit := make(map[int64]struct{}, len(used))
  493. usedSum := money.Amount(0)
  494. for i := range used {
  495. if !used[i].CreateTime.Before(cutoff) {
  496. continue
  497. }
  498. amount, err := money.Parse(used[i].ActivityMoneyUsed)
  499. if err != nil {
  500. return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  501. }
  502. usedSum = usedSum.Add(amount)
  503. activityHit[used[i].ActivityID] = struct{}{}
  504. }
  505. total = total.Add(usedSum)
  506. for activityID := range activityHit {
  507. total = total.Add(signedByActivity[activityID])
  508. }
  509. return total, nil
  510. }
  511. // isBillSpendType 判断流水类型是否属于「当期消费」集合(dump 764175 的 IN 列表)。
  512. func isBillSpendType(logType int32) bool {
  513. for _, t := range billSpendTypes {
  514. if t == logType {
  515. return true
  516. }
  517. }
  518. return false
  519. }
  520. // parseCampusIDs 解析 dump 第二入参(逗号串 `"1,2,3"`;空串 → 空切片)。
  521. //
  522. // 入参:raw 原始字符串(可含空格)。
  523. // 出参:[]int64 解析结果(**仅回显**,不参与过滤);非法片段 → ErrBadParam。
  524. //
  525. // 说明:dump 该参数是**死参数**(764094 声明、过程体零引用),这里只做"格式合法"校验,
  526. // 让前端传错(如 "1,a")时立刻报错,而不是悄悄当作"没过滤"跑出一份"看起来对"的账单。
  527. func parseCampusIDs(raw string) ([]int64, error) {
  528. trimmed := strings.TrimSpace(raw)
  529. if trimmed == "" {
  530. return []int64{}, nil
  531. }
  532. parts := strings.Split(trimmed, ",")
  533. out := make([]int64, 0, len(parts))
  534. for _, part := range parts {
  535. item := strings.TrimSpace(part)
  536. if item == "" {
  537. continue
  538. }
  539. id, err := strconv.ParseInt(item, 10, 64)
  540. if err != nil || id <= 0 {
  541. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  542. errString("service: campusIds 必须是正整数逗号串(非法片段: "+item+")"))
  543. }
  544. out = append(out, id)
  545. }
  546. return out, nil
  547. }
  548. // billTypeTitle 流水类型的展示名(只用于明细展示;未登记类型返回 "其它")。
  549. //
  550. // 入参:logType 流水类型编码(model.ExpenseLogType*)。
  551. // 出参:中文标题常量。
  552. func billTypeTitle(logType int32) string {
  553. switch logType {
  554. case model.ExpenseLogTypeRecharge:
  555. return "充值"
  556. case model.ExpenseLogTypeCourseCampus, model.ExpenseLogTypeCourseCampusB:
  557. return "课程消费(校区)"
  558. case model.ExpenseLogTypeCourseSchoolA, model.ExpenseLogTypeCourseSchoolB,
  559. model.ExpenseLogTypeCourseSchoolC, model.ExpenseLogTypeCourseSchoolD:
  560. return "课程消费(学校)"
  561. case model.ExpenseLogTypeTransferOut:
  562. return "课费转结(转出)"
  563. case model.ExpenseLogTypeTransferIn:
  564. return "课费转结(转入)"
  565. case model.ExpenseLogTypeRecommend:
  566. return "老带新推荐"
  567. case model.ExpenseLogTypeBadDebt:
  568. return "坏账处理"
  569. case model.ExpenseLogTypeHistoryDeduct:
  570. return "历史扣费"
  571. case model.ExpenseLogTypeRefund:
  572. return "余额退费"
  573. case model.ExpenseLogTypeClearBalance:
  574. return "余额清零"
  575. case model.ExpenseLogTypeCancelRechargeAct:
  576. return "取消充值活动"
  577. default:
  578. return "其它"
  579. }
  580. }