"""学生表(需求 2.1),系统的主表。 关于「年龄」的一个设计取舍 -------------------------------------------------------------------------- 需求里把年龄列成了学生字段。这里**不落 age 字段**,只存 birth_date: * 年龄 = 按今天算出来的派生值。今天是 24 岁,明年今天还是 24 就错了, 必须靠定时任务去刷,多一个必定会脏的字段。 * 需要"按年龄筛选"的场景(需求 2.6.1)用 SQL 表达式算: ``app.core.utils.age_expression()`` 把 birth_date 折成年数参与 WHERE。 * 出参同时给 birth_date 和算好的 age,前端拿来直接用。 唯一例外:录入时对方只知道年龄不知道生日,入参允许只传 age, service 会用 ``guess_birth_date()`` 折算一个占位生日并标记 ``birth_date_estimated=True``。 """ from __future__ import annotations from datetime import date from sqlalchemy import Date, ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.model.base import Base, SoftDeleteMixin, TimestampMixin from app.model.constants import GENDER_TEXT, STUDENT_STATUS_TEXT, StudentStatus from app.core.utils import calc_age class Student(Base, TimestampMixin, SoftDeleteMixin): __tablename__ = "student" __table_args__ = {"comment": "学生基本信息表"} id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) stu_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False, index=True, comment="学号(按规则生成,非自增)") name: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="姓名") gender: Mapped[int] = mapped_column(default=1, nullable=False, index=True, comment="性别 1=男 2=女") birth_date: Mapped[date | None] = mapped_column(Date, comment="出生日期(年龄由此实时计算,不落库)") birth_date_estimated: Mapped[int] = mapped_column(default=0, nullable=False, comment="生日是否为按年龄推算的占位值 0=否 1=是") native_place: Mapped[str | None] = mapped_column(String(60), comment="籍贯") graduate_school: Mapped[str | None] = mapped_column(String(80), comment="毕业院校") major: Mapped[str | None] = mapped_column(String(60), comment="专业") education: Mapped[str | None] = mapped_column(String(20), comment="学历") enroll_date: Mapped[date | None] = mapped_column(Date, comment="入学时间") graduate_date: Mapped[date | None] = mapped_column(Date, comment="毕业时间") phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话") id_card: Mapped[str | None] = mapped_column(String(30), comment="身份证号") class_id: Mapped[int | None] = mapped_column(ForeignKey("clazz.id"), index=True, comment="所属班级") advisor_id: Mapped[int | None] = mapped_column(ForeignKey("advisor.id"), index=True, comment="顾问编号") status: Mapped[int] = mapped_column( default=int(StudentStatus.STUDYING), nullable=False, index=True, comment="状态 1=在读 2=进入就业 3=已就业", ) remark: Mapped[str | None] = mapped_column(Text, comment="备注(成绩预警会自动追加)") # ---------------- 关系 ---------------- klass: Mapped["Clazz"] = relationship( # noqa: F821 back_populates="students", lazy="joined", foreign_keys=[class_id] ) advisor: Mapped["Advisor"] = relationship(lazy="joined") # noqa: F821 scores: Mapped[list["Score"]] = relationship( # noqa: F821 back_populates="student", lazy="selectin", order_by="Score.exam_seq", cascade="all, delete-orphan", ) employment: Mapped["Employment"] = relationship( # noqa: F821 back_populates="student", uselist=False, lazy="joined", ) # ---------------- 出参派生字段 ---------------- @property def age(self) -> int | None: return calc_age(self.birth_date) @property def gender_text(self) -> str | None: return GENDER_TEXT.get(self.gender) @property def status_text(self) -> str: return STUDENT_STATUS_TEXT.get(self.status, "未知") @property def class_name(self) -> str | None: return self.klass.name if self.klass and self.klass.is_del == 0 else None @property def class_no(self) -> str | None: return self.klass.class_no if self.klass and self.klass.is_del == 0 else None @property def advisor_name(self) -> str | None: return self.advisor.name if self.advisor and self.advisor.is_del == 0 else None @property def score_count(self) -> int: return len([s for s in self.scores if s.is_del == 0]) @property def avg_score(self) -> float | None: valid = [float(s.score) for s in self.scores if s.is_del == 0 and s.score is not None] return round(sum(valid) / len(valid), 2) if valid else None @property def fail_count(self) -> int: return len([s for s in self.scores if s.is_del == 0 and s.flag == 1])