Files
X-Financial/server/src/app/services/document_numbering.py

98 lines
2.8 KiB
Python
Raw Normal View History

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-"))