bx_codegen.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """报销单号/标题生成(从 bx-backend 迁移,增加 tenant_no 隔离)。"""
  2. from __future__ import annotations
  3. import re
  4. from datetime import date, datetime
  5. from sqlalchemy import func, text
  6. from sqlalchemy.orm import Session
  7. from app.models.bx_models import BxReimburseApply
  8. _AUTO_TITLE_SUFFIX_RE = re.compile(r"报销单\d{11}$")
  9. def generate_apply_code(db: Session, prefix: str, tenant_no: str) -> str:
  10. """
  11. 生成顺序单号:{prefix}{YYYYMMDD}{3位序号}。
  12. prefix 示例:BYJ(备用金)、BX(报销)。
  13. 说明:
  14. - uk_apply_code 为全局唯一(不含 tenant / del_flag),故取号时按全库、含软删统计,
  15. 避免复用仍被唯一索引占用的号段。
  16. - tenant_no 保留以兼容调用方;序号本身不按租户隔离。
  17. - 并发下仍可能撞号,上层创建须在 IntegrityError 时重新取号重试。
  18. """
  19. today = datetime.now().strftime("%Y%m%d")
  20. like_pattern = f"{prefix}{today}%"
  21. # 先锁住当日相关行,降低并发下同时读到同一 MAX 的概率
  22. db.execute(
  23. text(
  24. "SELECT apply_code FROM bx_advance_apply "
  25. "WHERE apply_code LIKE :p FOR UPDATE"
  26. ),
  27. {"p": like_pattern},
  28. )
  29. db.execute(
  30. text(
  31. "SELECT apply_code FROM bx_reimburse_apply "
  32. "WHERE apply_code LIKE :p FOR UPDATE"
  33. ),
  34. {"p": like_pattern},
  35. )
  36. result = db.execute(
  37. text(
  38. "SELECT MAX(CAST(SUBSTRING(apply_code, -3) AS UNSIGNED)) FROM ("
  39. "SELECT apply_code FROM bx_advance_apply WHERE apply_code LIKE :p1 "
  40. "UNION ALL "
  41. "SELECT apply_code FROM bx_reimburse_apply WHERE apply_code LIKE :p2"
  42. ") t"
  43. ),
  44. {"p1": like_pattern, "p2": like_pattern},
  45. ).scalar()
  46. seq = int(result or 0) + 1
  47. if seq > 999:
  48. raise ValueError(f"当日单号序号已用尽: {prefix}{today}")
  49. return f"{prefix}{today}{seq:03d}"
  50. def parse_reimburse_date(value: str | date | datetime | None) -> date:
  51. if value is None:
  52. return datetime.now().date()
  53. if isinstance(value, datetime):
  54. return value.date()
  55. if isinstance(value, date):
  56. return value
  57. text_value = str(value).strip()
  58. if not text_value:
  59. return datetime.now().date()
  60. return datetime.strptime(text_value, "%Y-%m-%d").date()
  61. def parse_optional_date(value: str | date | datetime | None) -> date | None:
  62. """可选日期:空 → None;合法 YYYY-MM-DD / date / datetime → date。"""
  63. if value is None:
  64. return None
  65. if isinstance(value, datetime):
  66. return value.date()
  67. if isinstance(value, date):
  68. return value
  69. text_value = str(value).strip()
  70. if not text_value:
  71. return None
  72. return datetime.strptime(text_value, "%Y-%m-%d").date()
  73. def format_reimburse_title(applicant_name: str, reimburse_date: date, seq: int) -> str:
  74. return f"{applicant_name}报销单{reimburse_date.strftime('%Y%m%d')}{seq:03d}"
  75. def is_auto_reimburse_title(title: str) -> bool:
  76. return bool(_AUTO_TITLE_SUFFIX_RE.search(str(title or "").strip()))
  77. def _title_seq_max_query(
  78. db: Session,
  79. tenant_no: str,
  80. reimburse_date: date,
  81. exclude_apply_id: str | None = None,
  82. ):
  83. """
  84. 同租户、同报销日期下的最大标题序号。
  85. uk_tenant_reimburse_date_title_seq 不含 del_flag,软删行仍占用序号。
  86. 取号若只统计未删除行,会再次分到已被占用的号(例如 1)并触发 1062。
  87. """
  88. query = db.query(func.max(BxReimburseApply.title_seq)).filter(
  89. BxReimburseApply.tenant_no == tenant_no,
  90. BxReimburseApply.reimburse_date == reimburse_date,
  91. BxReimburseApply.title_seq.isnot(None),
  92. )
  93. if exclude_apply_id:
  94. query = query.filter(BxReimburseApply.id != exclude_apply_id)
  95. return query
  96. def preview_reimburse_title_seq(
  97. db: Session,
  98. tenant_no: str,
  99. reimburse_date: date,
  100. exclude_apply_id: str | None = None,
  101. ) -> int:
  102. """预览序号(不加行锁,仅用于弹窗展示)。含软删行,与正式取号一致。"""
  103. max_seq = _title_seq_max_query(
  104. db, tenant_no, reimburse_date, exclude_apply_id
  105. ).scalar()
  106. return int(max_seq or 0) + 1
  107. def allocate_reimburse_title_seq(
  108. db: Session,
  109. tenant_no: str,
  110. reimburse_date: date,
  111. exclude_apply_id: str | None = None,
  112. ) -> int:
  113. """
  114. 分配同租户、同报销日期下的标题序号(MAX+1,含软删)。
  115. 配合 uk_tenant_reimburse_date_title_seq 唯一约束,并发冲突时由上层重试。
  116. """
  117. max_seq = _title_seq_max_query(
  118. db, tenant_no, reimburse_date, exclude_apply_id
  119. ).with_for_update().scalar()
  120. return int(max_seq or 0) + 1
  121. def generate_title(
  122. db: Session,
  123. applicant_name: str,
  124. tenant_no: str,
  125. reimburse_date: str | date | datetime | None = None,
  126. exclude_apply_id: str | None = None,
  127. ) -> str:
  128. """预览用标题生成(提交时仍会在服务端重新分配序号以保证并发安全)。"""
  129. rd = parse_reimburse_date(reimburse_date)
  130. seq = preview_reimburse_title_seq(db, tenant_no, rd, exclude_apply_id)
  131. return format_reimburse_title(applicant_name, rd, seq)
  132. def resolve_reimburse_title(
  133. db: Session,
  134. applicant_name: str,
  135. tenant_no: str,
  136. reimburse_date: date,
  137. requested_title: str | None,
  138. exclude_apply_id: str | None = None,
  139. ) -> tuple[str, int | None]:
  140. """
  141. 确定最终标题与 title_seq。
  142. - 未填标题或仍为自动标题格式时:按报销日期重新分配序号并生成标题。
  143. - 自定义标题:title_seq 为 None(不参与唯一序号约束)。
  144. """
  145. custom_title = str(requested_title or "").strip()
  146. if custom_title and not is_auto_reimburse_title(custom_title):
  147. return custom_title, None
  148. seq = allocate_reimburse_title_seq(
  149. db, tenant_no, reimburse_date, exclude_apply_id=exclude_apply_id
  150. )
  151. title = format_reimburse_title(applicant_name, reimburse_date, seq)
  152. return title, seq