109 lines
3.8 KiB
Python
109 lines
3.8 KiB
Python
"""通用工具:年龄表达式、类型转换、日期解析、分页计算。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
from math import ceil
|
||
from typing import Any
|
||
|
||
from sqlalchemy import Integer, func, text
|
||
from sqlalchemy.sql.elements import ColumnElement
|
||
|
||
from app.core.database import dialect_name
|
||
from app.core.exceptions import BusinessError
|
||
|
||
|
||
# ---------------------------------------------------------------- 年龄
|
||
def age_expression(birth_date_column, dialect: str | None = None) -> ColumnElement:
|
||
"""跨方言的"按当前日期计算年龄"SQL 表达式。
|
||
|
||
模型里不落 age 字段:年龄是随时间漂移的派生值,落库第二天就会错。
|
||
统计接口需要按年龄筛选,所以这里给出能在 WHERE / GROUP BY 里用的表达式。
|
||
"""
|
||
dialect = dialect or dialect_name()
|
||
if dialect == "sqlite":
|
||
return func.cast(
|
||
(func.julianday(func.current_date()) - func.julianday(birth_date_column)) / 365.25,
|
||
Integer,
|
||
)
|
||
# MySQL: TIMESTAMPDIFF(YEAR, birth_date, CURDATE())
|
||
return func.timestampdiff(text("YEAR"), birth_date_column, func.curdate())
|
||
|
||
|
||
def date_diff_days_expr(end_column, start_column, dialect: str | None = None) -> ColumnElement:
|
||
"""两个日期列相减得到天数,跨方言。
|
||
|
||
MySQL 用 DATEDIFF(end, start);SQLite 用 julianday 差值取整。
|
||
就业时长(需求 2.6.3)用它直接在 SQL 里算,不用把整表拉到内存。
|
||
"""
|
||
dialect = dialect or dialect_name()
|
||
if dialect == "sqlite":
|
||
return func.cast(func.julianday(end_column) - func.julianday(start_column), Integer)
|
||
return func.datediff(end_column, start_column)
|
||
|
||
|
||
def calc_age(birth: date | None, today: date | None = None) -> int | None:
|
||
"""Python 侧算年龄(列表出参用)。"""
|
||
if not birth:
|
||
return None
|
||
today = today or date.today()
|
||
return today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day))
|
||
|
||
|
||
def guess_birth_date(age: int, today: date | None = None) -> date:
|
||
"""只给了年龄没有生日时的兜底推算(取当年某月 1 日,避免 2/29 问题)。"""
|
||
today = today or date.today()
|
||
return date(today.year - age, 1, 1)
|
||
|
||
|
||
# ---------------------------------------------------------------- 类型转换
|
||
def to_float(value: Any) -> float | None:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, Decimal):
|
||
return float(value)
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def parse_date(value: Any, field: str = "日期") -> date | None:
|
||
"""schema 层日期一律用 str,转 date 在这一层做。"""
|
||
if value in (None, "", "null"):
|
||
return None
|
||
if isinstance(value, datetime):
|
||
return value.date()
|
||
if isinstance(value, date):
|
||
return value
|
||
if isinstance(value, str):
|
||
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y%m%d"):
|
||
try:
|
||
return datetime.strptime(value.strip(), fmt).date()
|
||
except ValueError:
|
||
continue
|
||
raise BusinessError(f"{field}格式不正确,应为 YYYY-MM-DD")
|
||
|
||
|
||
def parse_amount(value: Any, field: str = "金额") -> Decimal | None:
|
||
if value in (None, ""):
|
||
return None
|
||
try:
|
||
return Decimal(str(value))
|
||
except Exception as exc: # noqa: BLE001
|
||
raise BusinessError(f"{field}必须是数字") from exc
|
||
|
||
|
||
# ---------------------------------------------------------------- 分页
|
||
def page_count(total: int, page_size: int) -> int:
|
||
if page_size <= 0:
|
||
return 0
|
||
return ceil(total / page_size) if total else 0
|
||
|
||
|
||
def paginate_params(page: int, page_size: int) -> tuple[int, int]:
|
||
page = max(page or 1, 1)
|
||
page_size = min(max(page_size or 10, 1), 200)
|
||
return page, page_size
|