PayrollManagementSystem/backend/app/models/settlement.py
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00

205 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""月度结算与调整单模型(M3 ④:月结审批报表补充需求)。
- 月度结算按「确认回款」和计算快照生成;**通过即锁定**(status=3 已审定
即终态,锁定后源数据改动只产生提示与后续调整)
- settlement_items 明细按人固化(可负=负数工资),锁定后无 UPDATE 路径
(重生成 = 草稿/驳回态删旧插新)
- 调整单是已锁定月份的唯一调整通道(补发/扣减,走审批;生效后不回写
已锁定月结——append-only,报表汇总并入)
"""
from datetime import datetime
from decimal import Decimal
from sqlalchemy import JSON, DateTime, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 月结状态(monthly_settlements.status;通过即锁定——两态合一)
SETTLEMENT_STATUS_DRAFT = 1 # 草稿(可重新生成/提交)
SETTLEMENT_STATUS_PENDING = 2 # 待审批(提交后)
SETTLEMENT_STATUS_APPROVED = 3 # 已审定(终态=已锁定)
SETTLEMENT_STATUS_REJECTED = 4 # 已驳回(回草稿可重提)
# 调整单状态(adjustment_orders.status)
ADJUSTMENT_STATUS_PENDING = 1 # 待审批
ADJUSTMENT_STATUS_EFFECTIVE = 2 # 已生效
ADJUSTMENT_STATUS_REJECTED = 3 # 已驳回
ADJUSTMENT_STATUS_WITHDRAWN = 4 # 已撤回
# 调整方向(adjustment_orders.direction)
ADJUSTMENT_DIRECTION_EXTRA = 1 # 补发
ADJUSTMENT_DIRECTION_DEDUCT = 2 # 扣减
class MonthlySettlement(Base, TimestampMixin):
"""月度结算表:按月一行(settlement_month 唯一),快照 + 审批 + 锁定。"""
__tablename__ = "monthly_settlements"
__table_args__ = mysql_table_args("月度结算表(按月一行;通过即锁定,锁定后走调整单)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, unique=True, comment="结算月份(YYYY-MM;全表唯一)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
)
total_performance: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="绩效金额合计(可负)"
)
total_payable: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="应付绩效合计(可负)"
)
person_count: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="0", comment="结算人数"
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)"
)
approval_order_no: Mapped[str | None] = mapped_column(
String(32), comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)"
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)
locked_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="锁定时间(status=3 时回填)"
)
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)")
@property
def approval_required(self) -> bool:
return bool((self.snapshot_json or {}).get("approval_required", True))
class SettlementItem(Base):
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
__tablename__ = "settlement_items"
__table_args__ = mysql_table_args(
"月结明细表(按人固化;支持负数工资;锁定后只读)",
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
settlement_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("monthly_settlements.id", ondelete="CASCADE"),
nullable=False,
comment="月结ID(monthly_settlements.id;头删明细随删)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="结算人员ID(personnel.id)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="结算人员姓名(冗余固化)"
)
role_code: Mapped[str] = mapped_column(
String(32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
)
calc_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="核算类型:1单笔回款 2经理团队聚合"
)
performance_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
)
payable_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="应付绩效合计(可负)"
)
basic_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="月工资标准(personnel.salary_standard 当月快照)"
)
salary_mode: Mapped[str | None] = mapped_column(
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
)
net_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)"
)
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
)
probation_note: Mapped[str | None] = mapped_column(
String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)"
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="个人明细快照(台账行引用列表)"
)
class AdjustmentOrder(Base, TimestampMixin):
"""调整单:已锁定月份的唯一调整通道(补发/扣减,走审批;不回写月结)。"""
__tablename__ = "adjustment_orders"
__table_args__ = mysql_table_args(
"调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
adjustment_type: Mapped[str] = mapped_column(String(32), nullable=False, server_default="'GENERAL'", comment="调整类型:GENERAL普通 ALLOCATION分配差额")
source_ledger_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="分配差额对应的原台账ID")
attribution_json: Mapped[dict | None] = mapped_column(JSON, comment="原台账业务与分管归属快照")
order_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="调整人员ID(personnel.id)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="调整人员姓名(冗余固化)"
)
direction: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="方向:1补发 2扣减"
)
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="调整金额(恒正,方向表达语义)"
)
reason: Mapped[str] = mapped_column(
String(255), nullable=False, comment="扣补说明(必填,审批与追溯锚点)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)