feat(migration): V7 核算台账/月结/调整单四表与核算纯函数

- V7 四表:performance_ledgers(append-only 版本行,uk(receipt_id,calc_version)
  库级防重兜底)/monthly_settlements(月份 uk,通过即锁定终态)/
  settlement_items(按人固化可负工资,uk 三列)/adjustment_orders(TZ 单号)
- registry +1:approval.adjustment.enabled(19 项,BOOL 首组扩至三开关)
- formula 补核算纯函数:clamp_weight_r(从预览内联提取共用)/monthly_salary
  三口径/probation_annual_note/manager_team_share(#12 暂口径:基数不含
  经理个人户业绩,聚合集中一处答复后只改)
- 迁移 downgrade 只保留 drop_table+alter 回滚+种子行反向 DELETE;
  performance_ledgers 表名复数(test_models 红线断言)
- 断言同步:EXPECTED_TABLES 四新表/registry 19/BOOL 三开关/conftest
  清理四新表与 ParamValue 边界 22
- schema_sync+全量单测 460 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-16 16:32:32 +08:00
1 parent 30a179fc4c
commit a5c86640e2
17 files changed
+1464 -51

No files matched your search

@@ -0,0 +1,632 @@
"""V7 核算台账/月结/调整单四表与调整单开关种子
Revision ID: 3bca400421c6
Revises: 79bcc5aa98d4
Create Date: 2026-10-16 15:54:53.000890
"""
from collections.abc import Sequence
from datetime import date
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "3bca400421c6"
down_revision: str | Sequence[str] | None = "79bcc5aa98d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"monthly_settlements",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="结算月份(YYYY-MM;全表唯一)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
),
sa.Column(
"total_performance",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="绩效金额合计(可负)",
),
sa.Column(
"total_payable",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="应付绩效合计(可负)",
),
sa.Column(
"person_count", mysql.TINYINT(), server_default="0", nullable=False, comment="结算人数"
),
sa.Column(
"snapshot_json",
sa.JSON(),
nullable=True,
comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)",
),
sa.Column(
"approval_order_no",
sa.String(length=32),
nullable=True,
comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column("locked_at", sa.DateTime(), nullable=True, comment="锁定时间(status=3 时回填)"),
sa.Column(
"locked_by", sa.String(length=64), nullable=True, comment="锁定操作人(冗余固化)"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_monthly_settlements_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_monthly_settlements")),
sa.UniqueConstraint(
"settlement_month", name=op.f("uk_monthly_settlements_settlement_month")
),
comment="月度结算表(按月一行;通过即锁定,锁定后走调整单)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"adjustment_orders",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"order_no",
sa.String(length=32),
nullable=False,
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="调整人员ID(personnel.id)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="调整人员姓名(冗余固化)",
),
sa.Column("direction", mysql.TINYINT(), nullable=False, comment="方向:1补发 2扣减"),
sa.Column(
"amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="调整金额(恒正,方向表达语义)",
),
sa.Column(
"reason",
sa.String(length=255),
nullable=False,
comment="扣补说明(必填,审批与追溯锚点)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_adjustment_orders_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_adjustment_orders_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_adjustment_orders")),
sa.UniqueConstraint("order_no", name=op.f("uk_adjustment_orders_order_no")),
comment="调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"settlement_items",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"settlement_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="月结ID(monthly_settlements.id;头删明细随删)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="结算人员ID(personnel.id)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="结算人员姓名(冗余固化)",
),
sa.Column(
"role_code", sa.String(length=32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
),
sa.Column(
"calc_type",
mysql.TINYINT(),
nullable=False,
comment="核算类型:1单笔回款 2经理团队聚合",
),
sa.Column(
"performance_sum",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
),
sa.Column(
"payable_sum",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="应付绩效合计(可负)",
),
sa.Column(
"basic_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="月工资标准(personnel.salary_standard 当月快照)",
),
sa.Column(
"salary_mode",
sa.String(length=16),
nullable=True,
comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE",
),
sa.Column(
"net_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="净工资 = 绩效±基本工资(按口径;试用期 0)",
),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
),
sa.Column(
"probation_note",
sa.String(length=255),
nullable=True,
comment="试用期说明(年累计超阈值部分年底一次性结算)",
),
sa.Column(
"snapshot_json", sa.JSON(), nullable=True, comment="个人明细快照(台账行引用列表)"
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_settlement_items_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["settlement_id"],
["monthly_settlements.id"],
name=op.f("fk_settlement_items_settlement_id"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_settlement_items")),
comment="月结明细表(按人固化;支持负数工资;锁定后只读)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"performance_ledgers",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"receipt_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="回款ID(receipts.id;聚合行可为 NULL)",
),
sa.Column(
"calc_version",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="核算版本(重算+1,旧行 is_active=0)",
),
sa.Column(
"is_active",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
),
sa.Column(
"calc_type",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
),
sa.Column(
"business_no",
sa.String(length=32),
nullable=False,
comment="业务编号(冗余固化,列表免联查)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="归属营销人员ID(personnel.id;核算归属锚点)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="归属营销人员姓名(冗余固化)",
),
sa.Column(
"role_code",
sa.String(length=32),
nullable=False,
comment="岗位编码(business_roles.role_code,计提公式锚点)",
),
sa.Column(
"customer_type",
sa.String(length=32),
nullable=False,
comment="客户分类:personal/manager_personal/enterprise",
),
sa.Column(
"product_category",
sa.String(length=32),
nullable=False,
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
),
sa.Column("ship_date", sa.Date(), nullable=True, comment="发货日期(回款天数基准)"),
sa.Column(
"receipt_date",
sa.Date(),
nullable=False,
comment="回款日期(参数版本匹配日,裁决 #11)",
),
sa.Column(
"payment_days",
sa.Integer(),
nullable=True,
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)",
),
sa.Column(
"rate_text",
sa.String(length=32),
nullable=False,
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
),
sa.Column(
"converted_profit",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="折算毛利润额(可负;混合业务为各行汇总)",
),
sa.Column(
"performance_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="绩效金额(可负,裁决:负绩效照算不归零)",
),
sa.Column(
"barter_applied",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="以物抵顶折算:1已按 80% 折算 0否",
),
sa.Column(
"weight_r",
sa.Integer(),
nullable=True,
comment="支付权重 R(回款占销售额百分比向下取整)",
),
sa.Column(
"payment_weight",
sa.Numeric(precision=7, scale=6),
nullable=True,
comment="支付权重系数 R×R(展示用)",
),
sa.Column(
"payable_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)",
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)",
),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="试用期:1照算不发放 0否",
),
sa.Column(
"missing_cost",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
),
sa.Column(
"snapshot_json",
sa.JSON(),
nullable=True,
comment="计算快照(所用参数版本与行级折算明细,重算可回溯)",
),
sa.Column(
"settlement_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="所属月结ID(月结生成时回填;NULL=未入结)",
),
sa.Column(
"calc_reason",
sa.String(length=255),
nullable=True,
comment="核算/重算原因(重算必填留痕)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_performance_ledgers_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["receipt_id"],
["receipts.id"],
name=op.f("fk_performance_ledgers_receipt_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["settlement_id"],
["monthly_settlements.id"],
name=op.f("fk_performance_ledgers_settlement_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_performance_ledgers")),
sa.UniqueConstraint(
"receipt_id", "calc_version", name="uk_performance_ledgers_receipt_id_calc_version"
),
comment="绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_performance_ledgers_business_id", "performance_ledgers", ["business_id"], unique=False
)
op.create_index(
"idx_performance_ledgers_month_active",
"performance_ledgers",
["settlement_month", "is_active"],
unique=False,
)
op.create_index(
"idx_performance_ledgers_month_personnel_type",
"performance_ledgers",
["settlement_month", "personnel_id", "calc_type"],
unique=False,
)
op.create_index(
"idx_performance_ledgers_personnel_id",
"performance_ledgers",
["personnel_id"],
unique=False,
)
op.alter_column(
"approval_orders",
"order_type",
existing_type=mysql.TINYINT(),
comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
existing_comment="审批对象类型:1二次分配方案 2月度结算(预留)",
existing_nullable=False,
)
# ### end Alembic commands ###
# ---- 种子:调整单审批开关(V6 写法先例;definitions id 19 / values id 22)----
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 19,
"param_code": "approval.adjustment.enabled",
"param_name": "调整单审批开关",
"param_group": "审批开关",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道"
),
"sort_order": 19,
"status": 1,
}
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 22,
"param_id": 19,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
}
],
)
def downgrade() -> None:
"""回滚迁移。
整表回滚只保留 `op.drop_table`(V1/V3/V6 先例:外键唯一支撑索引单独
drop 会报 MySQL 1553)。行级种子(param_definitions 19 / param_values 22)
的宿主表由 V3 建、本迁移只插行,须显式 DELETE 否则重放 upgrade 撞唯一键。
删除顺序:先删引用方(ledger/items),再删被引用方(头表/调整单)。
"""
op.drop_table("performance_ledgers")
op.drop_table("settlement_items")
op.drop_table("adjustment_orders")
op.drop_table("monthly_settlements")
op.alter_column(
"approval_orders",
"order_type",
existing_type=mysql.TINYINT(),
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
existing_comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
existing_nullable=False,
)
op.execute("DELETE FROM param_values WHERE id = 22")
op.execute("DELETE FROM param_definitions WHERE id = 19")