PayrollManagementSystem/backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py
liuwenxin bc019cadf5 feat(system): 新增岗位角色与计提参数四表及 V3 迁移
- business_roles/param_definitions/param_change_batches/param_values 四表 + 种子(3 内置岗位/16 参数定义/19 版本条目/初始批次 P20260101-00)
- 双轨迁移:Alembic bfbda9c3191d + db/migration/V3 导出 + init-dev.sql 同步(schema_sync 校验通过)
- 参数值以 Fraction 可解析字符串存储(4/360 禁 0.011111);企业户 2‰→3‰ 双种子支持按到账日回溯

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 16:13:13 +08:00

811 lines
30 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.

"""新增岗位角色与计提参数四表
Revision ID: bfbda9c3191d
Revises: a45a8087831b
Create Date: 2026-10-12 16:01:48.760620
"""
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 = "bfbda9c3191d"
down_revision: str | Sequence[str] | None = "a45a8087831b"
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(
"business_roles",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"role_code",
sa.String(length=32),
nullable=False,
comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改",
),
sa.Column("role_name", sa.String(length=64), nullable=False, comment="岗位名称"),
sa.Column(
"calc_template",
sa.String(length=32),
nullable=False,
comment="计提公式模板编码(以代码内模板注册表为准)",
),
sa.Column("description", sa.String(length=255), nullable=True, comment="岗位说明"),
sa.Column(
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0禁用",
),
sa.Column(
"is_builtin",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否内置岗位:1内置(禁删禁停) 0自定义",
),
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.PrimaryKeyConstraint("id", name=op.f("pk_business_roles")),
sa.UniqueConstraint("role_code", name=op.f("uk_business_roles_role_code")),
comment="业务岗位角色表(与登录角色roles区分,决定计提公式与参数)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"param_definitions",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"param_code",
sa.String(length=64),
nullable=False,
comment="参数编码,如 salesperson.rate.slope",
),
sa.Column("param_name", sa.String(length=64), nullable=False, comment="参数名称"),
sa.Column(
"param_group",
sa.String(length=32),
nullable=False,
comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径",
),
sa.Column(
"value_type",
sa.String(length=16),
nullable=False,
comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT",
),
sa.Column(
"unit", sa.String(length=16), nullable=True, comment="展示单位:‰/%/元/天,无单位为空"
),
sa.Column(
"dimension_type",
sa.String(length=32),
server_default="NONE",
nullable=False,
comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE",
),
sa.Column(
"options_text",
sa.String(length=255),
nullable=True,
comment="枚举选项(竖线分隔),仅 ENUM 使用",
),
sa.Column(
"default_value",
sa.String(length=64),
nullable=True,
comment="系统默认值(Fraction 可解析字符串)",
),
sa.Column(
"value_min", sa.String(length=64), nullable=True, comment="取值下限(含),校验用"
),
sa.Column(
"value_max", sa.String(length=64), nullable=True, comment="取值上限(含),校验用"
),
sa.Column(
"description", sa.String(length=255), nullable=True, comment="口径说明(配置页展示)"
),
sa.Column(
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0停用",
),
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.PrimaryKeyConstraint("id", name=op.f("pk_param_definitions")),
sa.UniqueConstraint("param_code", name=op.f("uk_param_definitions_param_code")),
comment="计提参数定义表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"param_change_batches",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"version_no", sa.String(length=32), nullable=False, comment="版本号,如 P20261012-01"
),
sa.Column("effective_date", sa.Date(), nullable=False, comment="生效日期(业务日期口径)"),
sa.Column(
"change_type",
mysql.TINYINT(),
nullable=False,
comment="生效方式:1立即生效 2指定日期生效",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="变更说明"),
sa.Column(
"operator_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"operator_name",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余固化)",
),
sa.Column(
"item_count", sa.Integer(), server_default="0", nullable=False, comment="变更参数项数"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_param_change_batches_operator_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_change_batches")),
sa.UniqueConstraint("version_no", name=op.f("uk_param_change_batches_version_no")),
comment="参数变更批次表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"param_values",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"param_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="参数定义ID(param_definitions.id)",
),
sa.Column(
"dimension_value",
sa.String(length=32),
server_default=sa.text("''"),
nullable=False,
comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值",
),
sa.Column(
"effective_date", sa.Date(), nullable=False, comment="生效日期(按业务日期时点化取值)"
),
sa.Column(
"value_text",
sa.String(length=64),
nullable=False,
comment="参数值(Fraction 可解析字符串/枚举码/开关)",
),
sa.Column(
"old_value_text", sa.String(length=64), nullable=True, comment="变更前值(留痕)"
),
sa.Column(
"batch_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="变更批次ID(param_change_batches.id)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["batch_id"],
["param_change_batches.id"],
name=op.f("fk_param_values_batch_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["param_id"],
["param_definitions.id"],
name=op.f("fk_param_values_param_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_values")),
comment="参数版本条目表(append-only,按生效日期时点化取值)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_param_values_batch_id", "param_values", ["batch_id"], unique=False)
op.create_index(
"idx_param_values_effective_date", "param_values", ["effective_date"], unique=False
)
op.create_index(
"idx_param_values_param_id_dimension_value_effective_date",
"param_values",
["param_id", "dimension_value", "effective_date"],
unique=False,
)
# ### end Alembic commands ###
# ---- 种子数据(生产与开发同源;口径依据 2026-10-12 业务裁决对照表)----
# 显式指定 id:四张表均为本迁移新建、应用时必为空表,id 稳定可控;
# param_values 按 param_id 引用定义,无法依赖自增(bulk_insert 取不回自增值)。
op.bulk_insert(
sa.table(
"business_roles",
sa.column("id", sa.BigInteger),
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("calc_template", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("is_builtin", sa.Integer),
),
[
{
"id": 1,
"role_code": "salesperson",
"role_name": "营销员",
"calc_template": "TPL_SALESPERSON_DECAY",
"description": "业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)",
"sort_order": 1,
"is_builtin": 1,
},
{
"id": 2,
"role_code": "sales_manager",
"role_name": "营销经理",
"calc_template": "TPL_MANAGER_SHARE",
"description": "经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
"sort_order": 2,
"is_builtin": 1,
},
{
"id": 3,
"role_code": "info_officer",
"role_name": "信息员",
"calc_template": "TPL_INFO_SALES_FIXED",
"description": "信息员绩效:本人销售额×1‰",
"sort_order": 3,
"is_builtin": 1,
},
],
)
op.bulk_insert(
sa.table(
"param_change_batches",
sa.column("id", sa.BigInteger),
sa.column("version_no", sa.String),
sa.column("effective_date", sa.Date),
sa.column("change_type", sa.Integer),
sa.column("remark", sa.String),
sa.column("operator_name", sa.String),
sa.column("item_count", sa.Integer),
),
[
{
"id": 1,
"version_no": "P20260101-00",
"effective_date": date(2026, 1, 1),
"change_type": 1,
"remark": "初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)",
"operator_name": "system",
"item_count": 19,
}
],
)
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),
),
[
{
"id": 1,
"param_code": "salesperson.rate.start",
"param_name": "业务员起始计提比例",
"param_group": "业务员个人户",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "10",
"value_min": "0",
"value_max": None,
"description": "线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
"sort_order": 1,
},
{
"id": 2,
"param_code": "salesperson.rate.slope",
"param_name": "业务员日递减斜率",
"param_group": "业务员个人户",
"value_type": "DECIMAL",
"unit": "‰/天",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "4/360",
"value_min": "0",
"value_max": None,
"description": "精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
"sort_order": 2,
},
{
"id": 3,
"param_code": "salesperson.rate.floor",
"param_name": "业务员计提下限",
"param_group": "业务员个人户",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "4",
"value_min": "0",
"value_max": None,
"description": "回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
"sort_order": 3,
},
{
"id": 4,
"param_code": "salesperson.days.boundary",
"param_name": "线性递减分界天数",
"param_group": "业务员个人户",
"value_type": "INT",
"unit": "天",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "360",
"value_min": "0",
"value_max": None,
"description": "≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
"sort_order": 4,
},
{
"id": 5,
"param_code": "manager.share.team",
"param_name": "经理本团队分成比例",
"param_group": "经理分成",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "12.5",
"value_min": "0",
"value_max": "100",
"description": "∑本人带领业务员绩效 × 此比例(限本团队)",
"sort_order": 5,
},
{
"id": 6,
"param_code": "manager.share.company",
"param_name": "经理全公司分成比例",
"param_group": "经理分成",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "5",
"value_min": "0",
"value_max": "100",
"description": "∑全部业务员绩效 × 此比例(全公司)",
"sort_order": 6,
},
{
"id": 7,
"param_code": "enterprise.rate",
"param_name": "企业户计提比例",
"param_group": "企业户",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "PRODUCT_CATEGORY",
"options_text": None,
"default_value": "3",
"value_min": "0",
"value_max": None,
"description": "所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
"sort_order": 7,
},
{
"id": 8,
"param_code": "info.rate",
"param_name": "信息员计提比例",
"param_group": "信息员",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "1",
"value_min": "0",
"value_max": None,
"description": "信息员绩效 = 本人销售额 × 此比例",
"sort_order": 8,
},
{
"id": 9,
"param_code": "prepay.rate",
"param_name": "预付款计提比例",
"param_group": "支付环节",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "NONE",
"options_text": None,
"default_value": "10",
"value_min": "0",
"value_max": None,
"description": "预付款统一按此比例计提",
"sort_order": 9,
},
{
"id": 10,
"param_code": "barter.discount",
"param_name": "以物抵顶折算比例",
"param_group": "支付环节",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "NONE",
"options_text": None,
"default_value": "80",
"value_min": "0",
"value_max": "100",
"description": "以物抵顶回款绩效按应发绩效的此比例折算",
"sort_order": 10,
},
{
"id": 11,
"param_code": "payment.start.ratio",
"param_name": "支付起付比例",
"param_group": "支付环节",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "NONE",
"options_text": None,
"default_value": "60",
"value_min": "0",
"value_max": "100",
"description": "单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
"sort_order": 11,
},
{
"id": 12,
"param_code": "payment.weight.mode",
"param_name": "支付权重应用方式",
"param_group": "支付环节",
"value_type": "ENUM",
"unit": None,
"dimension_type": "NONE",
"options_text": "FULL|BALANCE",
"default_value": "FULL",
"value_min": None,
"value_max": None,
"description": "FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
"sort_order": 12,
},
{
"id": 13,
"param_code": "product.threshold",
"param_name": "折算毛利润额阈值",
"param_group": "产品阈值",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "PRODUCT_CATEGORY",
"options_text": None,
"default_value": "30",
"value_min": "0",
"value_max": "100",
"description": "折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
"sort_order": 13,
},
{
"id": 14,
"param_code": "salary.basic.mode",
"param_name": "月工资基本工资口径",
"param_group": "月工资口径",
"value_type": "ENUM",
"unit": None,
"dimension_type": "BUSINESS_ROLE",
"options_text": "INCLUDE|DEDUCT|EXCLUDE",
"default_value": "INCLUDE",
"value_min": None,
"value_max": None,
"description": "INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
"sort_order": 14,
},
{
"id": 15,
"param_code": "probation.annual.threshold",
"param_name": "试用期年累计结算阈值",
"param_group": "月工资口径",
"value_type": "DECIMAL",
"unit": "元",
"dimension_type": "NONE",
"options_text": None,
"default_value": "18000",
"value_min": "0",
"value_max": None,
"description": "试用期年累计绩效超过此值部分年底一次性结算",
"sort_order": 15,
},
{
"id": 16,
"param_code": "probation.salary.basic",
"param_name": "试用期基本工资标准",
"param_group": "月工资口径",
"value_type": "DECIMAL",
"unit": "元",
"dimension_type": "NONE",
"options_text": None,
"default_value": "3000",
"value_min": "0",
"value_max": None,
"description": "试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
"sort_order": 16,
},
],
)
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),
),
[
# 业务员个人户(2026-02-17 折算公式执行日起)
{
"id": 1,
"param_id": 1,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "10",
"batch_id": 1,
},
{
"id": 2,
"param_id": 2,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "4/360",
"batch_id": 1,
},
{
"id": 3,
"param_id": 3,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "4",
"batch_id": 1,
},
{
"id": 4,
"param_id": 4,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "360",
"batch_id": 1,
},
# 经理与信息员(2026-08-13 执行日起)
{
"id": 5,
"param_id": 5,
"dimension_value": "sales_manager",
"effective_date": date(2026, 8, 13),
"value_text": "12.5",
"batch_id": 1,
},
{
"id": 6,
"param_id": 6,
"dimension_value": "sales_manager",
"effective_date": date(2026, 8, 13),
"value_text": "5",
"batch_id": 1,
},
{
"id": 7,
"param_id": 7,
"dimension_value": "",
"effective_date": date(2026, 2, 17),
"value_text": "2",
"batch_id": 1,
},
{
"id": 8,
"param_id": 7,
"dimension_value": "",
"effective_date": date(2026, 8, 13),
"value_text": "3",
"batch_id": 1,
},
{
"id": 9,
"param_id": 8,
"dimension_value": "info_officer",
"effective_date": date(2026, 8, 13),
"value_text": "1",
"batch_id": 1,
},
# 支付环节与通用口径(需规默认值,无历史冲突)
{
"id": 10,
"param_id": 9,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "10",
"batch_id": 1,
},
{
"id": 11,
"param_id": 10,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "80",
"batch_id": 1,
},
{
"id": 12,
"param_id": 11,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "60",
"batch_id": 1,
},
{
"id": 13,
"param_id": 12,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "FULL",
"batch_id": 1,
},
# 产品阈值(按产品类别维度)
{
"id": 14,
"param_id": 13,
"dimension_value": "municipal_water",
"effective_date": date(2026, 2, 17),
"value_text": "30",
"batch_id": 1,
},
{
"id": 15,
"param_id": 13,
"dimension_value": "rail_transit",
"effective_date": date(2026, 2, 17),
"value_text": "20",
"batch_id": 1,
},
# 月工资口径(按岗位维度)
{
"id": 16,
"param_id": 14,
"dimension_value": "salesperson",
"effective_date": date(2026, 1, 1),
"value_text": "INCLUDE",
"batch_id": 1,
},
{
"id": 17,
"param_id": 14,
"dimension_value": "sales_manager",
"effective_date": date(2026, 1, 1),
"value_text": "DEDUCT",
"batch_id": 1,
},
{
"id": 18,
"param_id": 15,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "18000",
"batch_id": 1,
},
{
"id": 19,
"param_id": 16,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "3000",
"batch_id": 1,
},
],
)
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
# 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
# 原 autogenerate 输出的逐个 drop_index 会失败:组合索引最左列 param_id 与
# idx_param_values_batch_id 分别是两个外键的唯一支撑索引(MySQL errno 1553)。
# 删除顺序:先删引用方 param_values,再删被引用方。
op.drop_table("param_values")
op.drop_table("param_change_batches")
op.drop_table("param_definitions")
op.drop_table("business_roles")