From bc019cadf5d34a126eddf369f327ae3bf864a391 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 12 Oct 2026 16:13:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(system):=20=E6=96=B0=E5=A2=9E=E5=B2=97?= =?UTF-8?q?=E4=BD=8D=E8=A7=92=E8=89=B2=E4=B8=8E=E8=AE=A1=E6=8F=90=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=9B=9B=E8=A1=A8=E5=8F=8A=20V3=20=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ...da9c3191d_add_business_roles_and_params.py | 810 ++++++++++++++++++ backend/app/models/__init__.py | 6 + backend/app/models/business_role.py | 46 + backend/app/models/param.py | 159 ++++ backend/tests/unit/test_models.py | 4 + db/init/init-dev.sql | 140 ++- .../V3__add_business_roles_and_params.sql | 153 ++++ db/queries/计提参数.sql | 58 ++ 8 files changed, 1375 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py create mode 100644 backend/app/models/business_role.py create mode 100644 backend/app/models/param.py create mode 100644 db/migration/V3__add_business_roles_and_params.sql create mode 100644 db/queries/计提参数.sql diff --git a/backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py b/backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py new file mode 100644 index 0000000..a9bccbe --- /dev/null +++ b/backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py @@ -0,0 +1,810 @@ +"""新增岗位角色与计提参数四表 + +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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c99f584..826689d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,15 +10,21 @@ """ from app.models.base import Base, TimestampMixin +from app.models.business_role import BusinessRole from app.models.login_log import LoginLog from app.models.operation_log import OperationLog +from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue from app.models.role import Permission, Role, RolePermission from app.models.user import User, UserRole __all__ = [ "Base", + "BusinessRole", "LoginLog", "OperationLog", + "ParamChangeBatch", + "ParamDefinition", + "ParamValue", "Permission", "Role", "RolePermission", diff --git a/backend/app/models/business_role.py b/backend/app/models/business_role.py new file mode 100644 index 0000000..3076010 --- /dev/null +++ b/backend/app/models/business_role.py @@ -0,0 +1,46 @@ +"""业务岗位角色模型。 + +与系统登录角色(`roles`,权限维度)严格区分:本表是**业务岗位**维度, +决定人员适用的计提公式模板与参数。需求 #28 要求岗位角色可增删、 +新增岗位时在系统设置中绑定公式与参数(无需改代码),故模板编码 +存于本表、公式实现留在代码内注册表(一期不引入规则引擎)。 +""" + +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args + + +class BusinessRole(Base, TimestampMixin): + """业务岗位角色表。内置三个岗位(营销员/营销经理/信息员)随迁移初始化。""" + + __tablename__ = "business_roles" + __table_args__ = mysql_table_args("业务岗位角色表(与登录角色roles区分,决定计提公式与参数)") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + role_code: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改", + ) + role_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称") + calc_template: Mapped[str] = mapped_column( + String(32), nullable=False, comment="计提公式模板编码(以代码内模板注册表为准)" + ) + description: Mapped[str | None] = mapped_column(String(255), comment="岗位说明") + sort_order: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="显示排序" + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用" + ) + is_builtin: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="是否内置岗位:1内置(禁删禁停) 0自定义", + ) diff --git a/backend/app/models/param.py b/backend/app/models/param.py new file mode 100644 index 0000000..2404742 --- /dev/null +++ b/backend/app/models/param.py @@ -0,0 +1,159 @@ +"""计提参数模型:定义、变更批次与版本条目。 + +存储设计(M1 评审定稿): +- 参数值按**生效日期时点化**存储(逐项版本条目,append-only),核算按业务 + 日期回溯取值(对照表 #11:按回款到账日匹配参数版本),禁读「当前值」算历史 +- 「版本」由变更批次承载:一次保存 = 一个批次 = 一条变更记录;条目逐项留痕 + (旧值/新值),满足 #28 版本管理与留痕(需规 2.3.1 四) +- 值一律为 Fraction 可解析字符串(如 "4/360",禁 0.011111),解析不经 float +- **不设 (param_id, dimension_value, effective_date) 唯一键**:同日多次修正 + 是合法留痕,解析取 (effective_date, id) 最新一条;唯一键会把同日二次变更 + 逼成覆盖写、丢失中间值 +- 批次与条目均为 append-only(仅 create_time、无 update_time,同 login_logs + 先例):带 ON UPDATE 的列会让「未改也被刷新」造成留痕假象 +""" + +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args + + +class ParamDefinition(Base, TimestampMixin): + """计提参数定义表。目录元数据(类型/单位/维度/取值域),随迁移种子初始化。""" + + __tablename__ = "param_definitions" + __table_args__ = mysql_table_args("计提参数定义表") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + param_code: Mapped[str] = mapped_column( + String(64), nullable=False, unique=True, comment="参数编码,如 salesperson.rate.slope" + ) + param_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称") + param_group: Mapped[str] = mapped_column( + String(32), + nullable=False, + comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径", + ) + value_type: Mapped[str] = mapped_column( + String(16), nullable=False, comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT" + ) + unit: Mapped[str | None] = mapped_column(String(16), comment="展示单位:‰/%/元/天,无单位为空") + dimension_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + server_default="NONE", + comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE", + ) + options_text: Mapped[str | None] = mapped_column( + String(255), comment="枚举选项(竖线分隔),仅 ENUM 使用" + ) + default_value: Mapped[str | None] = mapped_column( + String(64), comment="系统默认值(Fraction 可解析字符串)" + ) + value_min: Mapped[str | None] = mapped_column(String(64), comment="取值下限(含),校验用") + value_max: Mapped[str | None] = mapped_column(String(64), comment="取值上限(含),校验用") + description: Mapped[str | None] = mapped_column(String(255), comment="口径说明(配置页展示)") + sort_order: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="显示排序" + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用" + ) + + +class ParamChangeBatch(Base): + """参数变更批次表(append-only)。 + + 一次提交 = 一个批次;版本号(如 P20261012-01)供对比与追溯引用。 + 未生效批次可整批撤回(删批次与条目);已生效条目禁改禁删。 + """ + + __tablename__ = "param_change_batches" + __table_args__ = mysql_table_args("参数变更批次表") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + version_no: Mapped[str] = mapped_column( + String(32), nullable=False, unique=True, comment="版本号,如 P20261012-01" + ) + effective_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="生效日期(业务日期口径)" + ) + change_type: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="生效方式:1立即生效 2指定日期生效" + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="变更说明") + operator_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="操作人ID(账号删除后置空)", + ) + operator_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="操作人用户名(冗余固化)" + ) + item_count: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="变更参数项数" + ) + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) + + +class ParamValue(Base): + """参数版本条目表(append-only)。 + + 解析规则:按 (param_id, dimension_value) 取 effective_date <= 业务日期 的 + ORDER BY effective_date DESC, id DESC 首条(同日多次变更取最新)。 + dimension_value 用空串而非 NULL:MySQL 组合索引对 NULL 不去重,空串语义更稳。 + """ + + __tablename__ = "param_values" + __table_args__ = mysql_table_args( + "参数版本条目表(append-only,按生效日期时点化取值)", + Index( + "idx_param_values_param_id_dimension_value_effective_date", + "param_id", + "dimension_value", + "effective_date", + ), + Index("idx_param_values_effective_date", "effective_date"), + Index("idx_param_values_batch_id", "batch_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + param_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("param_definitions.id", ondelete="RESTRICT"), + nullable=False, + comment="参数定义ID(param_definitions.id)", + ) + dimension_value: Mapped[str] = mapped_column( + String(32), + nullable=False, + server_default=text("''"), + comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值", + ) + effective_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="生效日期(按业务日期时点化取值)" + ) + value_text: Mapped[str] = mapped_column( + String(64), nullable=False, comment="参数值(Fraction 可解析字符串/枚举码/开关)" + ) + old_value_text: Mapped[str | None] = mapped_column(String(64), comment="变更前值(留痕)") + batch_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("param_change_batches.id", ondelete="RESTRICT"), + nullable=False, + comment="变更批次ID(param_change_batches.id)", + ) + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index 528ce19..d3b8069 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -19,6 +19,10 @@ EXPECTED_TABLES = { "role_permissions", "login_logs", "operation_logs", + "business_roles", + "param_definitions", + "param_change_batches", + "param_values", } diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 4a6e6fc..b713020 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -16,6 +16,10 @@ SET NAMES utf8mb4; -- 删除顺序遵循外键依赖:先删子表 +DROP TABLE IF EXISTS `param_values`; +DROP TABLE IF EXISTS `param_change_batches`; +DROP TABLE IF EXISTS `param_definitions`; +DROP TABLE IF EXISTS `business_roles`; DROP TABLE IF EXISTS `operation_logs`; DROP TABLE IF EXISTS `role_permissions`; DROP TABLE IF EXISTS `user_roles`; @@ -167,6 +171,89 @@ CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id); CREATE INDEX idx_operation_logs_username ON operation_logs (username); +-- --------------------------------------------------------------------------- +-- 业务岗位角色表(V3 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE business_roles ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改', + role_name VARCHAR(64) NOT NULL COMMENT '岗位名称', + calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)', + description VARCHAR(255) COMMENT '岗位说明', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1', + is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_roles PRIMARY KEY (id), + CONSTRAINT uk_business_roles_role_code UNIQUE (role_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 计提参数定义表(V3 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE param_definitions ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope', + param_name VARCHAR(64) NOT NULL COMMENT '参数名称', + param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径', + value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT', + unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空', + dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE', + options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用', + default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)', + value_min VARCHAR(64) COMMENT '取值下限(含),校验用', + value_max VARCHAR(64) COMMENT '取值上限(含),校验用', + description VARCHAR(255) COMMENT '口径说明(配置页展示)', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_param_definitions PRIMARY KEY (id), + CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 参数变更批次表(V3 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE param_change_batches ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01', + effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)', + change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效', + remark VARCHAR(255) COMMENT '变更说明', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_change_batches PRIMARY KEY (id), + CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 参数版本条目表(V3 迁移,append-only) +-- --------------------------------------------------------------------------- +CREATE TABLE param_values ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)', + dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '', + effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)', + value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)', + old_value_text VARCHAR(64) COMMENT '变更前值(留痕)', + batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_values PRIMARY KEY (id), + CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT, + CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_param_values_batch_id ON param_values (batch_id); + +CREATE INDEX idx_param_values_effective_date ON param_values (effective_date); + +CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date); + -- --------------------------------------------------------------------------- -- 字典数据:角色(与 V1 迁移中的初始化数据一致) -- --------------------------------------------------------------------------- @@ -174,6 +261,57 @@ INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2); +-- --------------------------------------------------------------------------- +-- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决) +-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致 +-- --------------------------------------------------------------------------- +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES +(1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1), +(2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1), +(3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1); + +INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES +(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES +(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1), +(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2), +(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3), +(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4), +(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5), +(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6), +(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7), +(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8), +(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9), +(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10), +(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11), +(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12), +(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13), +(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14), +(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15), +(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES +(1, 1, 'salesperson', '2026-02-17', '10', 1), +(2, 2, 'salesperson', '2026-02-17', '4/360', 1), +(3, 3, 'salesperson', '2026-02-17', '4', 1), +(4, 4, 'salesperson', '2026-02-17', '360', 1), +(5, 5, 'sales_manager', '2026-08-13', '12.5', 1), +(6, 6, 'sales_manager', '2026-08-13', '5', 1), +(7, 7, '', '2026-02-17', '2', 1), +(8, 7, '', '2026-08-13', '3', 1), +(9, 8, 'info_officer', '2026-08-13', '1', 1), +(10, 9, '', '2026-01-01', '10', 1), +(11, 10, '', '2026-01-01', '80', 1), +(12, 11, '', '2026-01-01', '60', 1), +(13, 12, '', '2026-01-01', 'FULL', 1), +(14, 13, 'municipal_water', '2026-02-17', '30', 1), +(15, 13, 'rail_transit', '2026-02-17', '20', 1), +(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1), +(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1), +(18, 15, '', '2026-01-01', '18000', 1), +(19, 16, '', '2026-01-01', '3000', 1); + -- --------------------------------------------------------------------------- -- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作 -- --------------------------------------------------------------------------- @@ -182,4 +320,4 @@ CREATE TABLE alembic_version ( CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; -INSERT INTO alembic_version (version_num) VALUES ('a45a8087831b'); +INSERT INTO alembic_version (version_num) VALUES ('bfbda9c3191d'); diff --git a/db/migration/V3__add_business_roles_and_params.sql b/db/migration/V3__add_business_roles_and_params.sql new file mode 100644 index 0000000..15ad0b5 --- /dev/null +++ b/db/migration/V3__add_business_roles_and_params.sql @@ -0,0 +1,153 @@ +-- Running upgrade a45a8087831b -> bfbda9c3191d + +CREATE TABLE business_roles ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改', + role_name VARCHAR(64) NOT NULL COMMENT '岗位名称', + calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)', + description VARCHAR(255) COMMENT '岗位说明', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1', + is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_roles PRIMARY KEY (id), + CONSTRAINT uk_business_roles_role_code UNIQUE (role_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci; + +CREATE TABLE param_definitions ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope', + param_name VARCHAR(64) NOT NULL COMMENT '参数名称', + param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径', + value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT', + unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空', + dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE', + options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用', + default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)', + value_min VARCHAR(64) COMMENT '取值下限(含),校验用', + value_max VARCHAR(64) COMMENT '取值上限(含),校验用', + description VARCHAR(255) COMMENT '口径说明(配置页展示)', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_param_definitions PRIMARY KEY (id), + CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci; + +CREATE TABLE param_change_batches ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01', + effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)', + change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效', + remark VARCHAR(255) COMMENT '变更说明', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_change_batches PRIMARY KEY (id), + CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci; + +CREATE TABLE param_values ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)', + dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '', + effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)', + value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)', + old_value_text VARCHAR(64) COMMENT '变更前值(留痕)', + batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_values PRIMARY KEY (id), + CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT, + CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_param_values_batch_id ON param_values (batch_id); + +CREATE INDEX idx_param_values_effective_date ON param_values (effective_date); + +CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date); + +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1); + +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1); + +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1); + +INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES (1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (1, 1, 'salesperson', '2026-02-17', '10', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (2, 2, 'salesperson', '2026-02-17', '4/360', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (3, 3, 'salesperson', '2026-02-17', '4', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (4, 4, 'salesperson', '2026-02-17', '360', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (5, 5, 'sales_manager', '2026-08-13', '12.5', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (6, 6, 'sales_manager', '2026-08-13', '5', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (7, 7, '', '2026-02-17', '2', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (8, 7, '', '2026-08-13', '3', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (9, 8, 'info_officer', '2026-08-13', '1', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (10, 9, '', '2026-01-01', '10', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (11, 10, '', '2026-01-01', '80', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (12, 11, '', '2026-01-01', '60', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (13, 12, '', '2026-01-01', 'FULL', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (14, 13, 'municipal_water', '2026-02-17', '30', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (15, 13, 'rail_transit', '2026-02-17', '20', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (18, 15, '', '2026-01-01', '18000', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1); + +UPDATE alembic_version SET version_num='bfbda9c3191d' WHERE alembic_version.version_num = 'a45a8087831b'; + diff --git a/db/queries/计提参数.sql b/db/queries/计提参数.sql new file mode 100644 index 0000000..a4df868 --- /dev/null +++ b/db/queries/计提参数.sql @@ -0,0 +1,58 @@ +-- ============================================================================ +-- 计提参数查询留档(M1 参数体系) +-- +-- 时点化取值核心规则:按 (param_id, dimension_value) 分组,取 +-- effective_date <= :biz_date 的 ORDER BY effective_date DESC, id DESC 首条 +-- (同日多次变更取最新一条,不设唯一键以保证完整留痕)。 +-- 参数总量约百行级,全量快照一次取回后在应用层按覆盖链 +-- (角色维度 > 产品类别维度 > 默认行)选择,无需在 SQL 层做维度回退。 +-- ============================================================================ + +-- 1. 全量快照:某业务日期下每个(参数, 维度值)的最新生效值 +-- 窗口函数版(MySQL 8.0+),供 param_repository.resolve_values 使用 +SELECT t.param_code, t.dimension_value, t.value_text, t.effective_date +FROM ( + SELECT pd.param_code, + pv.dimension_value, + pv.value_text, + pv.effective_date, + ROW_NUMBER() OVER ( + PARTITION BY pv.param_id, pv.dimension_value + ORDER BY pv.effective_date DESC, pv.id DESC + ) AS rn + FROM param_values pv + JOIN param_definitions pd ON pd.id = pv.param_id + WHERE pv.effective_date <= :biz_date + AND pd.status = 1 +) t +WHERE t.rn = 1; + +-- 2. 单参数版本历史(变更留痕,供参数历史接口与导出) +SELECT pv.effective_date, + pv.dimension_value, + pv.old_value_text, + pv.value_text, + b.version_no, + b.operator_name, + b.create_time +FROM param_values pv +JOIN param_change_batches b ON b.id = pv.batch_id +WHERE pv.param_id = :param_id +ORDER BY pv.effective_date DESC, pv.id DESC; + +-- 3. 区间变更条目(对比视图辅助:应用层对两日期各执行查询 1 后逐项 diff, +-- 本查询列出区间内实际发生过的变更,供变更记录分页与导出) +SELECT pd.param_code, + pd.param_name, + pv.dimension_value, + pv.old_value_text, + pv.value_text, + pv.effective_date, + b.version_no, + b.operator_name +FROM param_values pv +JOIN param_definitions pd ON pd.id = pv.param_id +JOIN param_change_batches b ON b.id = pv.batch_id +WHERE pv.effective_date > :from_date + AND pv.effective_date <= :to_date +ORDER BY pv.effective_date, pv.id;