- DOCUMENT_NUMBER_PREFIXES 改为 A/R/D,新增短格式与旧格式正则并存识别,提取正则加边界锚定避免误匹配 - build_document_number 去掉时间戳段,统一生成 A+token 等紧凑单号,is_application_claim_no 兼容旧 AP-/APP- 前缀 - access_policy/status_registry/reimbursements/expense_claims/budget_support 统一复用 is_application_claim_no 判定申请单 - 同步 document_numbering 单元测试覆盖新旧两种格式
98 lines
2.8 KiB
Python
98 lines
2.8 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import secrets
|
|
from datetime import datetime
|
|
from typing import Callable, Literal
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.financial_record import ExpenseClaim
|
|
|
|
DocumentNumberKind = Literal["application", "reimbursement", "audit"]
|
|
|
|
DOCUMENT_NUMBER_PREFIXES: dict[DocumentNumberKind, str] = {
|
|
"application": "A",
|
|
"reimbursement": "R",
|
|
"audit": "D",
|
|
}
|
|
DOCUMENT_NUMBER_TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
DOCUMENT_NUMBER_TOKEN_LENGTH = 8
|
|
DOCUMENT_NUMBER_SHORT_BODY = (
|
|
rf"(?:A|R|D)[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
|
|
)
|
|
DOCUMENT_NUMBER_LEGACY_BODY = (
|
|
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
|
|
)
|
|
DOCUMENT_NUMBER_PATTERN = re.compile(
|
|
rf"^(?:{DOCUMENT_NUMBER_SHORT_BODY}|{DOCUMENT_NUMBER_LEGACY_BODY})$",
|
|
flags=re.IGNORECASE,
|
|
)
|
|
DOCUMENT_NUMBER_EXTRACT_PATTERN = re.compile(
|
|
rf"(?<![A-Z0-9])(?:"
|
|
rf"{DOCUMENT_NUMBER_SHORT_BODY}"
|
|
rf"|{DOCUMENT_NUMBER_LEGACY_BODY}"
|
|
r"|APP-\d{8}-[A-Z0-9]{6}"
|
|
r"|EXP-\d{6}-\d{3}"
|
|
r")(?![A-Z0-9])",
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def generate_document_token() -> str:
|
|
return "".join(
|
|
secrets.choice(DOCUMENT_NUMBER_TOKEN_ALPHABET)
|
|
for _ in range(DOCUMENT_NUMBER_TOKEN_LENGTH)
|
|
)
|
|
|
|
|
|
def build_document_number(
|
|
kind: DocumentNumberKind,
|
|
*,
|
|
timestamp: datetime | None = None,
|
|
token: str | None = None,
|
|
) -> str:
|
|
prefix = DOCUMENT_NUMBER_PREFIXES[kind]
|
|
normalized_token = (token or generate_document_token()).strip().upper()
|
|
if not re.fullmatch(
|
|
rf"[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
|
|
normalized_token,
|
|
):
|
|
raise ValueError("document number token must be 8 chars from the configured alphabet")
|
|
return f"{prefix}{normalized_token}"
|
|
|
|
|
|
def generate_unique_expense_claim_no(
|
|
db: Session,
|
|
kind: DocumentNumberKind,
|
|
*,
|
|
timestamp: datetime | None = None,
|
|
token_factory: Callable[[], str] = generate_document_token,
|
|
max_attempts: int = 8,
|
|
) -> str:
|
|
for _ in range(max_attempts):
|
|
candidate = build_document_number(
|
|
kind,
|
|
timestamp=timestamp,
|
|
token=token_factory(),
|
|
)
|
|
exists = db.scalar(
|
|
select(ExpenseClaim.id)
|
|
.where(ExpenseClaim.claim_no == candidate)
|
|
.limit(1)
|
|
)
|
|
if exists is None:
|
|
return candidate
|
|
raise RuntimeError(f"failed to generate a unique {kind} document number")
|
|
|
|
|
|
def is_application_claim_no(value: object) -> bool:
|
|
normalized = str(value or "").strip().upper()
|
|
return bool(
|
|
re.fullmatch(
|
|
rf"A[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
|
|
normalized,
|
|
)
|
|
) or normalized.startswith(("AP-", "APP-"))
|