Compare commits

..

No commits in common. "fbf044f240dffce918564dbad74bc7e48d73cd6b" and "c7212cd93f58aa583002006200e02d4413b25a17" have entirely different histories.

45 changed files with 334 additions and 6620 deletions

View File

@ -1,13 +1,12 @@
# 后端
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。已实现登录鉴权、系统管理(用户/角色/操作日志/日志查询)与 M1 参数体系(岗位角色管理、计提参数版本化/留痕/对比/预览/导出,2026-10-13);业务模块(基础/业务数据、核算、报表)按路线图推进。
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与系统管理(用户/角色/操作日志/日志查询),业务模块待需求澄清后开发。
## 职责
- 提供 REST 接口(统一前缀 `/api`)
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
- 系统管理(需求 2.5.1):用户管理、角色管理、岗位角色(#28 可扩展+公式模板绑定)、操作日志(自动审计)、操作与登录日志查询
- 计提参数体系(需求 #28):16 项参数目录、按生效日期版本化(回款到账日取值)、变更批次留痕/对比/导出、计算预览
- 系统管理(需求 2.5.1):用户管理、角色管理、操作日志(自动审计)、操作与登录日志查询
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
## 技术栈与版本

View File

@ -1,810 +0,0 @@
"""新增岗位角色与计提参数四表
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")

View File

@ -8,9 +8,7 @@ from fastapi import APIRouter
from app.api import health
from app.api.v1 import auth
from app.api.v1.system import business_roles as system_business_roles
from app.api.v1.system import logs as system_logs
from app.api.v1.system import params as system_params
from app.api.v1.system import roles as system_roles
from app.api.v1.system import users as system_users
@ -27,8 +25,6 @@ v1_router.include_router(auth.router)
system_router = APIRouter(prefix="/system")
system_router.include_router(system_users.router)
system_router.include_router(system_roles.router)
system_router.include_router(system_business_roles.router)
system_router.include_router(system_params.router)
system_router.include_router(system_logs.operation_router)
system_router.include_router(system_logs.login_router)
v1_router.include_router(system_router)

View File

@ -1,122 +0,0 @@
"""业务岗位角色管理接口(需求 #28:岗位可扩展 + 绑定公式模板)。
保护规则(内置岗位禁删禁停、编码不可改、模板须在注册表内)见
`business_role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.business_role import (
BusinessRoleCreateIn,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleStatusIn,
BusinessRoleUpdateIn,
TemplateOptionOut,
)
from app.services import business_role_service
router = APIRouter(prefix="/business-roles", tags=["系统管理 · 岗位角色"])
@router.get("", response_model=ApiResponse[PageResult[BusinessRoleOut]], summary="岗位列表")
def list_roles(
session: DbSession,
_: AdminUser,
query: Annotated[BusinessRoleQuery, Query()],
) -> ApiResponse[PageResult[BusinessRoleOut]]:
"""分页查询业务岗位角色(含停用)。"""
return ok(business_role_service.list_roles(session, query))
@router.post("", response_model=ApiResponse[BusinessRoleOut], summary="新增岗位")
def create_role(
session: DbSession,
current_user: AdminUser,
payload: BusinessRoleCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""新增岗位。编码全局唯一、创建后不可修改;模板须在注册表内;新岗位默认启用。"""
return ok(
business_role_service.create_role(session, payload, operator=current_user, meta=meta),
message="岗位已创建",
)
@router.get(
"/meta/templates",
response_model=ApiResponse[list[TemplateOptionOut]],
summary="公式模板下拉",
)
def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[TemplateOptionOut]]:
"""取公式模板下拉(含声明参数,供岗位表单展示)。
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
"""
return ok(business_role_service.list_template_options())
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
def get_role(
session: DbSession,
_: AdminUser,
role_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[BusinessRoleOut]:
"""按主键查询岗位详情。"""
return ok(business_role_service.get_role(session, role_id))
@router.put("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="编辑岗位")
def update_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: BusinessRoleUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""编辑岗位名称/模板/说明/排序。
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点。
"""
return ok(
business_role_service.update_role(
session, role_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.put(
"/{role_id}/status", response_model=ApiResponse[BusinessRoleOut], summary="启用/禁用岗位"
)
def set_status(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: BusinessRoleStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""启用或禁用岗位。内置岗位不可停用。"""
result = business_role_service.set_status(
session, role_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除岗位")
def delete_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。"""
business_role_service.delete_role(session, role_id, operator=current_user, meta=meta)
return ok(message="岗位已删除")

View File

@ -1,147 +0,0 @@
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读);
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志,
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
导出为文件流(非统一信封),前端以 blob 接收。
"""
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Path, Query
from fastapi.responses import StreamingResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.param import (
ParamBatchDetailOut,
ParamBatchOut,
ParamBatchQuery,
ParamCatalogItemOut,
ParamChangeBatchOut,
ParamChangeIn,
ParamDiffOut,
ParamHistoryOut,
ParamHistoryQuery,
ParamPreviewIn,
ParamPreviewOut,
)
from app.services import param_service
router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
@router.get(
"", response_model=ApiResponse[list[ParamCatalogItemOut]], summary="参数目录(含当前生效值)"
)
def get_catalog(
session: DbSession,
_: AdminUser,
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
) -> ApiResponse[list[ParamCatalogItemOut]]:
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
return ok(param_service.get_catalog(session, as_of=as_of))
@router.post("/changes", response_model=ApiResponse[ParamChangeBatchOut], summary="提交变更批次")
def submit_change(
session: DbSession,
current_user: AdminUser,
payload: ParamChangeIn,
meta: RequestMetaDep,
) -> ApiResponse[ParamChangeBatchOut]:
"""提交一批参数变更(1~50 项),返回版本号;立即生效或指定日期生效。"""
return ok(
param_service.submit_change(session, payload, operator=current_user, meta=meta),
message="变更已提交",
)
@router.get(
"/changes", response_model=ApiResponse[PageResult[ParamBatchOut]], summary="变更记录分页"
)
def list_changes(
session: DbSession,
_: AdminUser,
query: Annotated[ParamBatchQuery, Query()],
) -> ApiResponse[PageResult[ParamBatchOut]]:
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
return ok(param_service.list_batches(session, query))
@router.get(
"/changes/{batch_id}", response_model=ApiResponse[ParamBatchDetailOut], summary="批次明细"
)
def get_batch(
session: DbSession,
_: AdminUser,
batch_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ParamBatchDetailOut]:
"""批次头 + 逐项旧值 → 新值。"""
return ok(param_service.get_batch_detail(session, batch_id))
@router.delete("/changes/{batch_id}", response_model=ApiResponse[None], summary="撤回未生效批次")
def withdraw_batch(
session: DbSession,
current_user: AdminUser,
batch_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""撤回尚未生效的批次;已生效批次不可撤回。"""
param_service.withdraw_batch(session, batch_id, operator=current_user, meta=meta)
return ok(message="批次已撤回")
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
def compare(
session: DbSession,
_: AdminUser,
from_date: Annotated[date, Query(description="起始业务日期(含)")],
to_date: Annotated[date, Query(description="截止业务日期(含)")],
) -> ApiResponse[list[ParamDiffOut]]:
"""两个业务日期的全量参数快照逐项对比,changed 标记是否变化。"""
return ok(param_service.compare(session, from_date=from_date, to_date=to_date))
@router.get("/export", summary="导出变更记录 Excel")
def export_changes(
session: DbSession,
_: AdminUser,
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
) -> StreamingResponse:
"""变更记录导出为 Excel 文件流(非统一信封,前端以 blob 接收)。"""
content = param_service.export_changes(session, start_date=start_date, end_date=end_date)
filename = f"param_changes_{date.today():%Y%m%d}.xlsx"
return StreamingResponse(
iter([content]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
def preview(
session: DbSession,
_: AdminUser,
payload: ParamPreviewIn,
) -> ApiResponse[ParamPreviewOut]:
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
return ok(param_service.preview(session, payload))
@router.get(
"/{param_code}/history",
response_model=ApiResponse[PageResult[ParamHistoryOut]],
summary="参数版本历史",
)
def get_history(
session: DbSession,
_: AdminUser,
param_code: Annotated[str, Path(max_length=64)],
query: Annotated[ParamHistoryQuery, Query()],
) -> ApiResponse[PageResult[ParamHistoryOut]]:
"""单参数的版本历史(含版本号、变更人与旧值 → 新值),最新在前。"""
return ok(param_service.get_history(session, param_code, query))

View File

@ -37,22 +37,10 @@ PASSWORD_MAX_BYTES = 72
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
CAPTCHA_LENGTH = 4
# ===== 业务岗位编码(与 db/migration 种子一致,business_roles.role_code)=====
BUSINESS_ROLE_SALESPERSON = "salesperson"
BUSINESS_ROLE_SALES_MANAGER = "sales_manager"
BUSINESS_ROLE_INFO_OFFICER = "info_officer"
# ===== 产品类别码(与 param_values.dimension_value 口径一致)=====
PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
MODULE_USER = "用户管理"
MODULE_ROLE = "角色管理"
MODULE_BUSINESS_ROLE = "岗位角色"
MODULE_PARAM = "计提参数"
ACTION_CREATE = "新增"
ACTION_UPDATE = "修改"

View File

@ -29,8 +29,8 @@ _SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
),
# Authorization: Bearer xxx
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
# 手机号:138****8888(兼容带空格/横线分隔的写法,如 138 0013 8000)
(re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"),
# 手机号:138****8888
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"),
# 18 位身份证:保留前 6 位与后 4 位
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
)

View File

@ -10,21 +10,15 @@
"""
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",

View File

@ -1,46 +0,0 @@
"""业务岗位角色模型。
与系统登录角色(`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自定义",
)

View File

@ -1,159 +0,0 @@
"""计提参数模型:定义、变更批次与版本条目。
存储设计(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="创建时间"
)

View File

@ -1,88 +0,0 @@
"""业务岗位角色数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessRole
# ==================== 读取 ====================
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> BusinessRole | None:
"""按主键查询。删除校验走 ``for_update`` 锁行(同 role_repository 的理由)。"""
if for_update:
stmt = select(BusinessRole).where(BusinessRole.id == role_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(BusinessRole, role_id)
def get_by_code(session: Session, role_code: str) -> BusinessRole | None:
"""按编码查询岗位。编码是参数维度值(dimension_value)的锚点。"""
stmt = select(BusinessRole).where(BusinessRole.role_code == role_code)
return session.execute(stmt).scalar_one_or_none()
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[BusinessRole]:
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
"""统计符合条件的岗位总数,供分页使用。"""
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
不转义时 ``_`` 会当单字符通配执行。
"""
if keyword:
stmt = stmt.where(
or_(
BusinessRole.role_code.contains(keyword, autoescape=True),
BusinessRole.role_name.contains(keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(BusinessRole.status == status)
return stmt
def count_personnel_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
"""批量统计各岗位下的人员数(删除保护的依据)。
② 基础数据管理落地 personnel 表后在此实现真实计数(一次 GROUP BY,
禁止列表循环内逐行查询);当前无人员表,恒返回空,删除校验对空字典放行。
"""
return {}
# ==================== 写入 ====================
def create(session: Session, role: BusinessRole) -> BusinessRole:
"""新增岗位。只 flush 以取得自增 id。"""
session.add(role)
session.flush()
return role
def delete(session: Session, role: BusinessRole) -> None:
"""删除岗位。调用方须先完成保护规则校验。"""
session.delete(role)
session.flush()

View File

@ -32,21 +32,7 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。
# phone/mobile/id_card 系 CLAUDE.md「日志禁输出手机号/身份证」红线的键名口径 ——
# 带分隔符的号码会穿过文本正则,必须在这里按整值抹除
_SENSITIVE_KEY_PARTS = (
"password",
"passwd",
"pwd",
"secret",
"token",
"app_key",
"access_key",
"phone",
"mobile",
"id_card",
"idcard",
)
_SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key")
_MASK = "***"

View File

@ -1,227 +0,0 @@
"""计提参数数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
时点化解析查询(窗口函数版)留存 db/queries/计提参数.sql。
"""
from datetime import date
from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
# ==================== 读取 ====================
def resolve_values(session: Session, biz_date: date) -> list[tuple[str, str, str, date]]:
"""时点化解析:每个 (param_id, dimension_value) 取生效日期 <= 业务日期的最新条目。
排序 (effective_date DESC, id DESC) 保证同日多次变更取最新一条
(不设日期唯一键,同日修正是合法留痕)。仅返回启用状态的参数定义。
返回 (param_code, dimension_value, value_text, effective_date)。
"""
row_number = (
func.row_number()
.over(
partition_by=(ParamValue.param_id, ParamValue.dimension_value),
order_by=(ParamValue.effective_date.desc(), ParamValue.id.desc()),
)
.label("rn")
)
subq = (
select(
ParamDefinition.param_code.label("param_code"),
ParamValue.dimension_value.label("dimension_value"),
ParamValue.value_text.label("value_text"),
ParamValue.effective_date.label("effective_date"),
row_number,
)
.join(ParamDefinition, ParamDefinition.id == ParamValue.param_id)
.where(ParamValue.effective_date <= biz_date, ParamDefinition.status == 1)
.subquery()
)
stmt = select(
subq.c.param_code, subq.c.dimension_value, subq.c.value_text, subq.c.effective_date
).where(subq.c.rn == 1)
return [
(row.param_code, row.dimension_value, row.value_text, row.effective_date)
for row in session.execute(stmt)
]
def latest_value_before(
session: Session, param_id: int, dimension_value: str, biz_date: date
) -> ParamValue | None:
"""取某参数某维度在业务日期(含)之前的最新条目 —— 提交变更时记录「旧值」。"""
stmt = (
select(ParamValue)
.where(
ParamValue.param_id == param_id,
ParamValue.dimension_value == dimension_value,
ParamValue.effective_date <= biz_date,
)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
.limit(1)
)
return session.execute(stmt).scalar_one_or_none()
def list_definitions(session: Session, *, status: int | None = None) -> list[ParamDefinition]:
"""参数定义列表(目录)。"""
stmt = select(ParamDefinition).order_by(ParamDefinition.sort_order, ParamDefinition.id)
if status is not None:
stmt = stmt.where(ParamDefinition.status == status)
return list(session.execute(stmt).scalars())
def get_definition_by_code(session: Session, param_code: str) -> ParamDefinition | None:
"""按编码取参数定义。"""
stmt = select(ParamDefinition).where(ParamDefinition.param_code == param_code)
return session.execute(stmt).scalar_one_or_none()
def list_history(
session: Session,
param_id: int,
*,
offset: int,
limit: int,
dimension_value: str | None = None,
) -> list[tuple[ParamValue, ParamChangeBatch]]:
"""单参数的版本历史(含批次信息),最新在前。"""
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
"""统计单参数的历史条目数。"""
stmt = select(func.count()).select_from(
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
)
return int(session.execute(stmt).scalar_one())
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
stmt = (
select(ParamValue, ParamChangeBatch)
.join(ParamChangeBatch, ParamChangeBatch.id == ParamValue.batch_id)
.where(ParamValue.param_id == param_id)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
)
if dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
return stmt
# ==================== 批次 ====================
def create_batch(session: Session, batch: ParamChangeBatch) -> ParamChangeBatch:
"""新增变更批次。只 flush 以取得自增 id。"""
session.add(batch)
session.flush()
return batch
def get_batch_by_id(
session: Session, batch_id: int, *, for_update: bool = False
) -> ParamChangeBatch | None:
"""按主键取批次。撤回校验走 ``for_update`` 锁行(防并发双撤)。"""
if for_update:
stmt = select(ParamChangeBatch).where(ParamChangeBatch.id == batch_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(ParamChangeBatch, batch_id)
def list_batches(
session: Session,
*,
offset: int,
limit: int,
start_date: date | None = None,
end_date: date | None = None,
) -> list[ParamChangeBatch]:
"""分页查询变更批次(按创建时间倒序)。"""
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
return list(
session.execute(
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
.offset(offset)
.limit(limit)
).scalars()
)
def count_batches(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> int:
"""统计符合条件的批次总数。"""
stmt = _batch_filters(
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
)
return int(session.execute(stmt).scalar_one())
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
if start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
if end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
return stmt
def list_values_by_batch(
session: Session, batch_id: int
) -> list[tuple[ParamValue, ParamDefinition]]:
"""批次内全部条目(join 参数定义取编码/名称),按 id 排序。"""
stmt = (
select(ParamValue, ParamDefinition)
.join(ParamDefinition, ParamDefinition.id == ParamValue.param_id)
.where(ParamValue.batch_id == batch_id)
.order_by(ParamValue.id)
)
return [(value, definition) for value, definition in session.execute(stmt)]
def delete_values_by_batch(session: Session, batch_id: int) -> None:
"""删除批次下全部条目(撤回未生效批次时使用,2.0 风格 delete 语句)。"""
session.execute(delete(ParamValue).where(ParamValue.batch_id == batch_id))
def delete_batch(session: Session, batch: ParamChangeBatch) -> None:
"""删除批次本体(调用方须先删条目并完成保护校验)。"""
session.delete(batch)
session.flush()
# ==================== 写入 ====================
def create_value(session: Session, value: ParamValue) -> ParamValue:
"""新增版本条目(append-only,禁改禁删已生效条目)。只 flush。"""
session.add(value)
session.flush()
return value
# ==================== 导出 ====================
def list_export_rows(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> list[tuple[ParamChangeBatch, ParamValue, ParamDefinition]]:
"""导出行:批次 × 条目 × 定义(变更人/时间/参数/维度/旧值/新值/生效日期)。"""
stmt = (
select(ParamChangeBatch, ParamValue, ParamDefinition)
.join(ParamValue, ParamValue.batch_id == ParamChangeBatch.id)
.join(ParamDefinition, ParamDefinition.id == ParamValue.param_id)
.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc(), ParamValue.id)
)
if start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
if end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
return [(batch, value, definition) for batch, value, definition in session.execute(stmt)]

View File

@ -1,90 +0,0 @@
"""业务岗位角色的请求/响应模型。"""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# 岗位编码规则与系统角色一致:小写字母开头,后接小写字母/数字/下划线,长度 2–32。
# 编码会作为参数维度值(param_values.dimension_value)与核算模板绑定的锚点,
# 保持单一风格避免大小写/全角混用造成对照困难
BUSINESS_ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
class BusinessRoleOut(BaseModel):
"""岗位响应(列表与详情共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
role_code: str
role_name: str
calc_template: str
description: str | None = None
sort_order: int = 0
status: int = 1
is_builtin: int = 0
class TemplateParamOut(BaseModel):
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
code: str
name: str
class TemplateOptionOut(BaseModel):
"""公式模板下拉项。"""
code: str
name: str
description: str
params: list[TemplateParamOut]
class BusinessRoleCreateIn(BaseModel):
"""新增岗位。"""
model_config = ConfigDict(str_strip_whitespace=True)
role_code: str = Field(min_length=2, max_length=32, description="岗位编码,创建后不可修改")
role_name: str = Field(min_length=1, max_length=64, description="岗位名称")
calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
@field_validator("role_code")
@classmethod
def _check_code(cls, value: str) -> str:
if not BUSINESS_ROLE_CODE_PATTERN.match(value):
raise ValueError("岗位编码须以小写字母开头,仅可含小写字母、数字与下划线")
return value
class BusinessRoleUpdateIn(BaseModel):
"""编辑岗位。
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点(同系统角色的保护理由)。
前端表单全量提交,故为全量替换语义。
"""
model_config = ConfigDict(str_strip_whitespace=True)
role_name: str = Field(min_length=1, max_length=64, description="岗位名称")
calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
class BusinessRoleStatusIn(BaseModel):
"""启用/禁用岗位。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class BusinessRoleQuery(PageQuery):
"""岗位列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")

View File

@ -1,238 +0,0 @@
"""计提参数管理的请求/响应模型。
字段命名与 user/role 一致采用蛇形直出(前端 TS 接口同构)。
金额类入参用字符串承载(Decimal 解析),杜绝 float 精度损失。
"""
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.response import PageQuery
def _parse_amount(value: str, *, field_name: str, allow_negative: bool) -> str:
"""金额字符串校验:必须可被 Decimal 解析;默认拒绝负数(毛利润额除外)。"""
try:
amount = Decimal(value.strip())
except InvalidOperation as exc:
raise ValueError(f"{field_name}必须是有效数字:{value}") from exc
if not amount.is_finite():
# 'NaN' 的比较会抛 InvalidOperation、'Infinity' 会在后续计算抛溢出,均须在入口拦下
raise ValueError(f"{field_name}必须为有限数字:{value}")
if not allow_negative and amount < 0:
raise ValueError(f"{field_name}不得为负数:{value}")
return value
class ParamDimensionValueOut(BaseModel):
"""某参数单个维度的当前生效值。"""
dimension_value: str
value_text: str
effective_date: date | None = None
class ParamCatalogItemOut(BaseModel):
"""参数目录项:定义元数据 + 各维度截至查询时点的生效值。"""
param_code: str
param_name: str
param_group: str
value_type: str
unit: str | None = None
dimension_type: str
options: list[str] = Field(default_factory=list)
default_value: str | None = None
value_min: str | None = None
value_max: str | None = None
description: str | None = None
values: list[ParamDimensionValueOut] = Field(default_factory=list)
class ParamHistoryOut(BaseModel):
"""单参数的版本历史条目(append-only 留痕)。"""
version_no: str
dimension_value: str
old_value_text: str | None = None
value_text: str
effective_date: date
operator_name: str
create_time: datetime
class ParamChangeItemIn(BaseModel):
"""变更条目:参数 + 维度 + 新值。"""
model_config = ConfigDict(str_strip_whitespace=True)
param_code: str = Field(min_length=1, max_length=64)
dimension_value: str = Field(default="", max_length=32)
value: str = Field(min_length=1, max_length=64)
class ParamChangeIn(BaseModel):
"""提交变更批次。
change_type=1 立即生效(生效日期取今日);change_type=2 指定日期生效,
生效日期必填且不得早于今日(过去没有「预约」的意义,历史回溯走重算授权)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
change_type: int = Field(ge=1, le=2, description="生效方式:1立即生效 2指定日期生效")
effective_date: date | None = Field(default=None, description="指定日期生效时必填")
remark: str | None = Field(default=None, max_length=255)
items: list[ParamChangeItemIn] = Field(min_length=1, max_length=50)
@model_validator(mode="after")
def _check_effective_date(self) -> "ParamChangeIn":
if self.change_type == 2 and self.effective_date is None:
raise ValueError("指定日期生效时必须提供生效日期")
return self
class ParamChangeBatchOut(BaseModel):
"""变更批次(提交/撤回/列表共用)。"""
id: int
version_no: str
effective_date: date
change_type: int
remark: str | None = None
operator_name: str
item_count: int
create_time: datetime
class ParamBatchItemOut(BaseModel):
"""批次明细条目(旧值 → 新值)。"""
param_code: str
param_name: str
dimension_value: str
old_value_text: str | None = None
value_text: str
effective_date: date
class ParamBatchOut(BaseModel):
"""变更批次(列表项)。"""
id: int
version_no: str
effective_date: date
change_type: int
remark: str | None = None
operator_name: str
item_count: int
create_time: datetime
class ParamBatchDetailOut(BaseModel):
"""批次明细:批次头 + 逐项变更。"""
id: int
version_no: str
effective_date: date
change_type: int
remark: str | None = None
operator_name: str
item_count: int
create_time: datetime
items: list[ParamBatchItemOut] = Field(default_factory=list)
class ParamDiffOut(BaseModel):
"""两日期快照对比项(全量参数,changed 标记是否变化)。"""
param_code: str
param_name: str
param_group: str
dimension_value: str
old_value: str | None = None
new_value: str | None = None
changed: bool
class ParamPreviewIn(BaseModel):
"""计算预览入参。金额用字符串承载,Decimal 解析。"""
model_config = ConfigDict(str_strip_whitespace=True)
business_role: str | None = Field(
default=None, max_length=32, description="岗位编码,如 salesperson"
)
customer_type: str | None = Field(
default=None, max_length=32, description="客户类型:personal个人户 enterprise企业户"
)
product_category: str | None = Field(
default=None, max_length=32, description="产品类别:municipal_water/rail_transit"
)
ship_date: date = Field(description="发货日")
receipt_date: date = Field(description="回款到账日")
revenue: str = Field(min_length=1, description="销售额(不含运费)")
profit: str = Field(min_length=1, description="毛利润额(可为负)")
received_amount: str = Field(min_length=1, description="本笔回款金额")
barter: bool = Field(default=False, description="是否以物抵顶")
probation: bool = Field(default=False, description="是否试用期")
@field_validator("revenue", "received_amount")
@classmethod
def _check_non_negative(cls, value: str, info) -> str:
return _parse_amount(value, field_name=info.field_name, allow_negative=False)
@field_validator("profit")
@classmethod
def _check_profit(cls, value: str, info) -> str:
return _parse_amount(value, field_name=info.field_name, allow_negative=True)
@model_validator(mode="after")
def _check_dates(self) -> "ParamPreviewIn":
if self.receipt_date < self.ship_date:
raise ValueError("回款到账日不得早于发货日")
return self
class ParamUsedOut(BaseModel):
"""预览实际用到的参数(对账透明:取值与生效时点)。"""
param_code: str
value_text: str
effective_date: date | None = None
class ParamPreviewOut(BaseModel):
"""计算预览出参。分数串(如 97/10000)为精确值,金额为 Decimal 串。"""
payment_days: int
rate: str
rate_text: str
profit_rate: str
converted_profit: str
performance_amount: str
barter_applied: bool
performance_after_barter: str | None = None
start_payment_ok: bool
payment_weight_r: int
payment_weight: str
payable_amount: str
probation_note: str | None = None
params_used: list[ParamUsedOut] = Field(default_factory=list)
class ParamBatchQuery(PageQuery):
"""变更记录查询条件。"""
start_date: date | None = Field(default=None, description="生效日期起(含)")
end_date: date | None = Field(default=None, description="生效日期止(含)")
class ParamHistoryQuery(PageQuery):
"""参数历史查询条件。"""
dimension_value: str | None = Field(
default=None, max_length=32, description="按维度值筛选,空为全部"
)

View File

@ -1,287 +0,0 @@
"""业务岗位角色管理业务编排。
需求 #28 的落点:岗位可增删、绑定公式模板(有限集合,代码内注册表),
新增岗位无需改代码。保护规则(前端会禁用相应按钮,**后端校验是唯一防线**):
- 内置岗位(is_builtin=1:营销员/营销经理/信息员)**禁删禁停** ——
参数种子与核算模板按编码锚定(如 salesperson 的递减公式参数行)
- `role_code` 创建后不可改 —— 它是参数维度值(param_values.dimension_value)
与模板绑定语义的锚点
- `calc_template` 必须是模板注册表(calc_templates.TEMPLATES)内的编码
- 有人员引用的岗位禁删 —— ② 落地 personnel 表后由引用计数生效,
当前钩子恒为空(见 business_role_repository.count_personnel_by_role)
"""
import logging
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_DISABLE,
ACTION_ENABLE,
ACTION_UPDATE,
MODULE_BUSINESS_ROLE,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import BusinessRole, User
from app.repositories import business_role_repository
from app.schemas.business_role import (
BusinessRoleCreateIn,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleUpdateIn,
TemplateOptionOut,
TemplateParamOut,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.calc_templates import TEMPLATES
from app.services.param_registry import PARAM_REGISTRY
logger = logging.getLogger(__name__)
def _to_out(role: BusinessRole) -> BusinessRoleOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return BusinessRoleOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
calc_template=role.calc_template,
description=role.description,
sort_order=role.sort_order,
status=role.status,
is_builtin=role.is_builtin,
)
def _ensure_template(calc_template: str) -> None:
"""模板必须存在于代码内注册表(前端为受控下拉,后端仍须校验)。"""
if calc_template not in TEMPLATES:
raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在")
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
"""分页查询岗位(含停用)。"""
roles = business_role_repository.list_roles(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = business_role_repository.count_roles(
session, keyword=query.keyword, status=query.status
)
return PageResult[BusinessRoleOut](
records=[_to_out(role) for role in roles],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
"""岗位详情。
:raises NotFoundError: 岗位不存在
"""
role = business_role_repository.get_by_id(session, role_id)
if role is None:
raise NotFoundError("岗位不存在")
return _to_out(role)
def list_template_options() -> list[TemplateOptionOut]:
"""公式模板下拉(含声明参数,供岗位表单展示与引导配置)。"""
options: list[TemplateOptionOut] = []
for tpl in TEMPLATES.values():
options.append(
TemplateOptionOut(
code=tpl.code,
name=tpl.name,
description=tpl.description,
params=[
TemplateParamOut(code=code, name=PARAM_REGISTRY[code].name)
for code in tpl.params
],
)
)
return options
def create_role(
session: Session,
payload: BusinessRoleCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""新增岗位。编码唯一、创建后不可修改,新岗位固定启用、非内置。
:raises BadRequestError: 编码已存在 / 模板不存在
"""
_ensure_template(payload.calc_template)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_CREATE,
summary=f"新增岗位 {payload.role_name}({payload.role_code})",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if business_role_repository.get_by_code(session, payload.role_code) is not None:
raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在")
role = BusinessRole(
role_code=payload.role_code,
role_name=payload.role_name,
calc_template=payload.calc_template,
description=payload.description,
sort_order=payload.sort_order,
status=1,
is_builtin=0,
)
try:
business_role_repository.create(session, role)
out = _to_out(role)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一编码会撞 uk_business_roles_role_code
session.rollback()
raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在") from exc
logger.info(
"新增岗位 | role_code=%s | template=%s", payload.role_code, payload.calc_template
)
return out
def update_role(
session: Session,
role_id: int,
payload: BusinessRoleUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""编辑岗位名称/模板/说明/排序(全量替换,不含 role_code)。
:raises NotFoundError: 岗位不存在
:raises BadRequestError: 模板不存在
"""
role = business_role_repository.get_by_id(session, role_id)
_ensure_template(payload.calc_template)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_UPDATE,
summary=f"修改岗位 {role.role_name if role else f'#{role_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role is None:
raise NotFoundError("岗位不存在")
role.role_name = payload.role_name
role.calc_template = payload.calc_template
role.description = payload.description
role.sort_order = payload.sort_order
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
out = _to_out(role)
session.commit()
logger.info("修改岗位 | role_id=%s | template=%s", role_id, payload.calc_template)
return out
def set_status(
session: Session,
role_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""启用或禁用岗位。内置岗位不可停用(参数种子按编码锚定)。
:raises NotFoundError: 岗位不存在
:raises ForbiddenError: 试图停用内置岗位
"""
role = business_role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
summary=f"{verb}岗位 {role.role_name if role else f'#{role_id}'}",
request_data={"status": status},
),
meta=meta,
):
if role is None:
raise NotFoundError("岗位不存在")
if role.is_builtin == 1 and status == 0:
raise ForbiddenError("内置岗位不可停用")
role.status = status
out = _to_out(role)
session.commit()
logger.info("变更岗位状态 | role_id=%s | status=%s", role_id, status)
return out
def delete_role(
session: Session,
role_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。
查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用(M2 接入后),
锁行使并发写入失败(可见)而非引用被静默悬空(不可见)。
:raises NotFoundError: 岗位不存在
:raises ForbiddenError: 试图删除内置岗位
:raises BadRequestError: 岗位下仍有人引用
"""
role = business_role_repository.get_by_id(session, role_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_DELETE,
summary=f"删除岗位 {role.role_name if role else f'#{role_id}'}",
),
meta=meta,
):
if role is None:
raise NotFoundError("岗位不存在")
if role.is_builtin == 1:
raise ForbiddenError("内置岗位不可删除")
counts = business_role_repository.count_personnel_by_role(session, [role_id])
person_count = counts.get(role_id, 0)
if person_count > 0:
raise BadRequestError(f"该岗位下仍有 {person_count} 名人员,请先调整归属")
role_code = role.role_code # 固化:对象随删除失效
business_role_repository.delete(session, role)
session.commit()
logger.info("删除岗位 | role_code=%s", role_code)

View File

@ -1,72 +0,0 @@
"""计提公式模板注册表(代码内有限集合)。
一期不引入规则引擎的落地方式:公式骨架固定(本表枚举),变化的是参数值
(系统设置维护)。岗位角色(business_roles.calc_template)绑定模板编码,
新增岗位 = 选模板 + 配参数,无需改代码(需求 #28)。
新增公式形态(如未来「按产品数量计提」)需要新增模板 —— 一期接受,
岗位表单中模板为带说明的受控下拉,避免误以为可自由写公式。
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class TemplateSpec:
"""公式模板元数据。"""
code: str
name: str
params: tuple[str, ...]
description: str
TEMPLATES: dict[str, TemplateSpec] = {
"TPL_SALESPERSON_DECAY": TemplateSpec(
code="TPL_SALESPERSON_DECAY",
name="业务员个人户·天数线性递减",
params=(
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
),
description="分段:≤分界天数 = 起始 − 天数×斜率;>分界天数 = 下限",
),
"TPL_MANAGER_SHARE": TemplateSpec(
code="TPL_MANAGER_SHARE",
name="营销经理·团队与全公司分成",
params=("manager.share.team", "manager.share.company"),
description="聚合:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
),
"TPL_ENTERPRISE_FIXED": TemplateSpec(
code="TPL_ENTERPRISE_FIXED",
name="企业户·固定计提",
params=("enterprise.rate",),
description="乘法:折算毛利润额 × 计提比例",
),
"TPL_INFO_SALES_FIXED": TemplateSpec(
code="TPL_INFO_SALES_FIXED",
name="信息员·销售额固定比例",
params=("info.rate",),
description="乘法:本人销售额 × 计提比例",
),
"TPL_PREPAY_FIXED": TemplateSpec(
code="TPL_PREPAY_FIXED",
name="预付款·固定计提",
params=("prepay.rate",),
description="乘法:回款额 × 计提比例",
),
}
def get_template(code: str) -> TemplateSpec:
"""取模板元数据。未注册编码属代码缺陷(500),不是用户错误。"""
tpl = TEMPLATES.get(code)
if tpl is None:
raise ValueError(f"公式模板编码未注册:{code}")
return tpl
def required_params(code: str) -> tuple[str, ...]:
"""模板声明的参数编码列表(岗位表单展示与校验用)。"""
return get_template(code).params

View File

@ -1,92 +0,0 @@
"""计提公式纯函数(无 IO、无会话)。
契约(与 M3 核算、计算预览共用,口径见 2026-10-12 业务裁决):
- 参数值按**存储口径**传入(含单位:比例参数为 ‰ 数如 10、百分比参数为 % 数如 60)
- 计算出的中间比率(如毛利润率)为**纯比例**(Fraction,如 9/20)
- 金额用 Decimal、比率用 Fraction,全程禁 float;Fraction→Decimal 转换在
分母仅含 2/5 因子时精确,否则按 Decimal 上下文精度(28 位)
- 绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3,禁按"互为倒数"实现)
"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
def to_decimal(value: Fraction) -> Decimal:
"""Fraction → Decimal。有限小数精确;循环小数按 Decimal 上下文精度(28 位)。"""
return Decimal(value.numerator) / Decimal(value.denominator)
# 模块内沿用私有名;公开别名供 service 层金额换算复用
_to_decimal = to_decimal
def payment_days(ship_date: date, receipt_date: date) -> int:
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
return (receipt_date - ship_date).days + 1
def salesperson_rate(
days: int,
*,
start: Fraction,
slope: Fraction,
floor: Fraction,
boundary_days: int,
) -> Fraction:
"""业务员计提比例(纯比例)。
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
"""
if days > boundary_days:
return floor / 1000
return (start - days * slope) / 1000
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction:
"""毛利润率(纯比例):毛利润额 / 销售额(**不含运费**口径,对照表 #2)。
销售额为 0 时返回 0(防除零;折算额随之为 0)。
"""
if revenue == 0:
return Fraction(0)
return Fraction(profit) / Fraction(revenue)
def converted_profit(revenue: Decimal, rate: Fraction, threshold: Fraction) -> Decimal:
"""折算毛利润额(化简式):销售额 × (毛利润率 − 阈值)。
rate 为纯比例(profit_rate 的输出);threshold 为 % 数(存储口径,如 30)。
化简式等价于定义式「毛利润额 / 毛利润率 × (毛利润率 − 阈值)」,且避免
毛利润率为 0 时除零(对照表 #12/#13)。毛利润率低于阈值时结果为负 ——
业务确认负值正常计算、不归零。
"""
return _to_decimal(Fraction(revenue) * (rate - threshold / 100))
def performance_amount(base: Decimal, rate: Fraction) -> Decimal:
"""绩效金额 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
return _to_decimal(Fraction(base) * rate)
def apply_barter(amount: Decimal, discount: Fraction) -> Decimal:
"""以物抵顶折算:应发绩效 × 折算比例(discount 为 % 数,默认 80,对照表 #24)。"""
return _to_decimal(Fraction(amount) * discount / 100)
def payment_weight(r: int) -> Fraction:
"""支付权重 R×R(纯比例):R 为回款占销售额百分比向下取整(对照表 #22)。
60 → 36/100;100 → 1(全额)。
"""
return Fraction(r, 100) * Fraction(r, 100)
def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bool:
"""起付判定:单项业务回款额达到销售额(不含运费)的 ratio%(对照表 #21)。"""
if revenue == 0:
return False
return Fraction(received) / Fraction(revenue) >= ratio / 100

View File

@ -1,304 +0,0 @@
"""计提参数目录注册表。
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
"""
from dataclasses import dataclass
from fractions import Fraction
from app.core.exceptions import BadRequestError
@dataclass(frozen=True, slots=True)
class ParamSpec:
"""单个参数的元数据(与 param_definitions 表字段一一对应)。"""
code: str
name: str
group: str
value_type: str
unit: str | None
dimension_type: str
options: tuple[str, ...] | None
default_value: str | None
value_min: str | None
value_max: str | None
description: str | None
sort_order: int
PARAM_REGISTRY: dict[str, ParamSpec] = {
spec.code: spec
for spec in [
ParamSpec(
code="salesperson.rate.start",
name="业务员起始计提比例",
group="业务员个人户",
value_type="DECIMAL",
unit="‰",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="10",
value_min="0",
value_max=None,
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
sort_order=1,
),
ParamSpec(
code="salesperson.rate.slope",
name="业务员日递减斜率",
group="业务员个人户",
value_type="DECIMAL",
unit="‰/天",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="4/360",
value_min="0",
value_max=None,
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
sort_order=2,
),
ParamSpec(
code="salesperson.rate.floor",
name="业务员计提下限",
group="业务员个人户",
value_type="DECIMAL",
unit="‰",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="4",
value_min="0",
value_max=None,
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
sort_order=3,
),
ParamSpec(
code="salesperson.days.boundary",
name="线性递减分界天数",
group="业务员个人户",
value_type="INT",
unit="天",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="360",
value_min="0",
value_max=None,
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
sort_order=4,
),
ParamSpec(
code="manager.share.team",
name="经理本团队分成比例",
group="经理分成",
value_type="DECIMAL",
unit="%",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="12.5",
value_min="0",
value_max="100",
description="∑本人带领业务员绩效 × 此比例(限本团队)",
sort_order=5,
),
ParamSpec(
code="manager.share.company",
name="经理全公司分成比例",
group="经理分成",
value_type="DECIMAL",
unit="%",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="5",
value_min="0",
value_max="100",
description="∑全部业务员绩效 × 此比例(全公司)",
sort_order=6,
),
ParamSpec(
code="enterprise.rate",
name="企业户计提比例",
group="企业户",
value_type="DECIMAL",
unit="‰",
dimension_type="PRODUCT_CATEGORY",
options=None,
default_value="3",
value_min="0",
value_max=None,
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
sort_order=7,
),
ParamSpec(
code="info.rate",
name="信息员计提比例",
group="信息员",
value_type="DECIMAL",
unit="‰",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="1",
value_min="0",
value_max=None,
description="信息员绩效 = 本人销售额 × 此比例",
sort_order=8,
),
ParamSpec(
code="prepay.rate",
name="预付款计提比例",
group="支付环节",
value_type="DECIMAL",
unit="‰",
dimension_type="NONE",
options=None,
default_value="10",
value_min="0",
value_max=None,
description="预付款统一按此比例计提",
sort_order=9,
),
ParamSpec(
code="barter.discount",
name="以物抵顶折算比例",
group="支付环节",
value_type="DECIMAL",
unit="%",
dimension_type="NONE",
options=None,
default_value="80",
value_min="0",
value_max="100",
description="以物抵顶回款绩效按应发绩效的此比例折算",
sort_order=10,
),
ParamSpec(
code="payment.start.ratio",
name="支付起付比例",
group="支付环节",
value_type="DECIMAL",
unit="%",
dimension_type="NONE",
options=None,
default_value="60",
value_min="0",
value_max="100",
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
sort_order=11,
),
ParamSpec(
code="payment.weight.mode",
name="支付权重应用方式",
group="支付环节",
value_type="ENUM",
unit=None,
dimension_type="NONE",
options=("FULL", "BALANCE"),
default_value="FULL",
value_min=None,
value_max=None,
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
sort_order=12,
),
ParamSpec(
code="product.threshold",
name="折算毛利润额阈值",
group="产品阈值",
value_type="DECIMAL",
unit="%",
dimension_type="PRODUCT_CATEGORY",
options=None,
default_value="30",
value_min="0",
value_max="100",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
sort_order=13,
),
ParamSpec(
code="salary.basic.mode",
name="月工资基本工资口径",
group="月工资口径",
value_type="ENUM",
unit=None,
dimension_type="BUSINESS_ROLE",
options=("INCLUDE", "DEDUCT", "EXCLUDE"),
default_value="INCLUDE",
value_min=None,
value_max=None,
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
sort_order=14,
),
ParamSpec(
code="probation.annual.threshold",
name="试用期年累计结算阈值",
group="月工资口径",
value_type="DECIMAL",
unit="元",
dimension_type="NONE",
options=None,
default_value="18000",
value_min="0",
value_max=None,
description="试用期年累计绩效超过此值部分年底一次性结算",
sort_order=15,
),
ParamSpec(
code="probation.salary.basic",
name="试用期基本工资标准",
group="月工资口径",
value_type="DECIMAL",
unit="元",
dimension_type="NONE",
options=None,
default_value="3000",
value_min="0",
value_max=None,
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
sort_order=16,
),
]
}
def get_spec(code: str) -> ParamSpec:
"""取参数元数据。未注册编码属代码缺陷(500),不是用户错误。"""
spec = PARAM_REGISTRY.get(code)
if spec is None:
raise ValueError(f"参数编码未注册:{code}")
return spec
def parse_fraction(text: str) -> Fraction:
"""Fraction 精确解析("4/360" → 1/90,不经 float)。非法输入抛 400。"""
try:
return Fraction(text.strip())
except (ValueError, ZeroDivisionError) as exc:
raise BadRequestError(f"参数值无法解析为数值:{text}") from exc
def validate_and_normalize(spec: ParamSpec, raw: str) -> str:
"""按元数据校验并规范化参数值,返回可入库字符串。
DECIMAL:Fraction 解析 + 取值域校验,分数式原样保留("4/360" 不截断);
INT:必须整数;BOOL:仅 0/1;ENUM:必须命中 options;TEXT:原样返回。
"""
value = raw.strip()
if spec.value_type in {"DECIMAL", "INT"}:
fraction = parse_fraction(value)
if spec.value_type == "INT" and fraction.denominator != 1:
raise BadRequestError(f"参数 {spec.code} 必须为整数:{raw}")
if spec.value_min is not None and fraction < parse_fraction(spec.value_min):
raise BadRequestError(f"参数 {spec.code} 低于取值下限 {spec.value_min}:{raw}")
if spec.value_max is not None and fraction > parse_fraction(spec.value_max):
raise BadRequestError(f"参数 {spec.code} 高于取值上限 {spec.value_max}:{raw}")
return value
if spec.value_type == "BOOL":
if value not in {"0", "1"}:
raise BadRequestError(f"参数 {spec.code} 仅接受 0/1:{raw}")
return value
if spec.value_type == "ENUM":
if not spec.options or value not in spec.options:
allowed = "/".join(spec.options or ())
raise BadRequestError(f"参数 {spec.code} 取值须为 {allowed}:{raw}")
return value
return value

View File

@ -1,664 +0,0 @@
"""计提参数服务:时点化取值、版本管理(变更/撤回/历史/对比)、计算预览与导出。
ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日期(回款到账日)
冻结当时口径,禁读「当前值」计算历史数据(算法红线)。
变更批次 = 版本(一次提交一批次);条目逐项留痕(旧值/新值),append-only。
"""
import logging
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from fractions import Fraction
from io import BytesIO
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_DELETE,
ACTION_UPDATE,
MODULE_PARAM,
PRODUCT_CATEGORIES,
)
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import ParamChangeBatch, ParamDefinition, ParamValue, User
from app.repositories import business_role_repository, param_repository
from app.schemas.param import (
ParamBatchDetailOut,
ParamBatchItemOut,
ParamBatchOut,
ParamBatchQuery,
ParamCatalogItemOut,
ParamChangeBatchOut,
ParamChangeIn,
ParamDiffOut,
ParamDimensionValueOut,
ParamHistoryOut,
ParamHistoryQuery,
ParamPreviewIn,
ParamPreviewOut,
ParamUsedOut,
)
from app.services import audit_service, formula
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import to_decimal
from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize
logger = logging.getLogger(__name__)
# ==================== 快照 ====================
@dataclass(frozen=True, slots=True)
class ParamSnapshot:
"""某业务日期下的参数快照(冻结不可变,核算期间保持一致)。
覆盖链:角色维度 > 产品类别维度 > 默认行('');均无匹配时回退注册表默认值。
"""
biz_date: date
_values: dict[str, dict[str, str]]
_effective: dict[tuple[str, str], date]
@classmethod
def from_rows(
cls, biz_date: date, rows: Iterable[tuple[str, str, str, date]]
) -> "ParamSnapshot":
"""由 (param_code, dimension_value, value_text, effective_date) 行构造。
rows 须按仓储输出顺序(同一 (code, dim) 的最新生效值在前)——以先出现者为准。
"""
values: dict[str, dict[str, str]] = {}
effective: dict[tuple[str, str], date] = {}
for code, dimension, value_text, effective_date in rows:
if (code, dimension) in effective:
continue
effective[(code, dimension)] = effective_date
values.setdefault(code, {}).setdefault(dimension, value_text)
return cls(biz_date=biz_date, _values=values, _effective=effective)
def get(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> str:
"""取参数原始值(覆盖链解析)。无值且无注册表默认值时抛 400。"""
candidates = self._values.get(code)
if candidates:
for dimension in (role_code, product_category, ""):
if dimension is not None and dimension in candidates:
return candidates[dimension]
spec = get_spec(code) # 未注册编码 → ValueError(代码缺陷)
if spec.default_value is None:
raise BadRequestError(f"参数 {code} 无生效值且无默认值,请先在系统设置中维护")
return spec.default_value
def effective_date_of(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> date | None:
"""取参数当前生效值的生效日期(回退注册表默认值时为 None)。"""
candidates = self._values.get(code)
if candidates:
for dimension in (role_code, product_category, ""):
if dimension is not None and (code, dimension) in self._effective:
return self._effective[(code, dimension)]
return None
def iter_items(self) -> Iterable[tuple[str, str, str]]:
"""遍历快照全部 (param_code, dimension_value, value_text),供对比。"""
for code, dims in self._values.items():
for dimension, value_text in dims.items():
yield code, dimension, value_text
def fraction(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> Fraction:
"""取参数值并精确解析为 Fraction("4/360" → 1/90,不经 float)。"""
return parse_fraction(
self.get(code, role_code=role_code, product_category=product_category)
)
def integer(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> int:
"""取参数值为整数。配置成非整数属数据缺陷(写入经校验,不应发生)。"""
value = self.fraction(code, role_code=role_code, product_category=product_category)
if value.denominator != 1:
raise ValueError(f"参数 {code} 配置为非整数:{value}")
return int(value)
def boolean(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> bool:
"""取布尔参数(0/1)。"""
return self.get(code, role_code=role_code, product_category=product_category) == "1"
def text(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> str:
"""取参数原文(枚举码/文本)。"""
return self.get(code, role_code=role_code, product_category=product_category)
def resolve_snapshot(session: Session, biz_date: date) -> ParamSnapshot:
"""按业务日期解析全量参数快照(M3 核算与计算预览共用入口)。"""
return ParamSnapshot.from_rows(biz_date, param_repository.resolve_values(session, biz_date))
# ==================== 目录 ====================
def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCatalogItemOut]:
"""参数目录:全部启用定义 + 各维度截至 asOf(默认今日)的生效值。"""
biz_date = as_of or date.today()
resolved = param_repository.resolve_values(session, biz_date)
by_code: dict[str, list[ParamDimensionValueOut]] = {}
for code, dimension, value_text, effective_date in resolved:
by_code.setdefault(code, []).append(
ParamDimensionValueOut(
dimension_value=dimension, value_text=value_text, effective_date=effective_date
)
)
items: list[ParamCatalogItemOut] = []
for definition in param_repository.list_definitions(session, status=1):
spec = get_spec(definition.param_code)
items.append(
ParamCatalogItemOut(
param_code=definition.param_code,
param_name=definition.param_name,
param_group=definition.param_group,
value_type=definition.value_type,
unit=definition.unit,
dimension_type=definition.dimension_type,
options=list(spec.options or ()),
default_value=definition.default_value,
value_min=definition.value_min,
value_max=definition.value_max,
description=definition.description,
values=by_code.get(definition.param_code, []),
)
)
return items
# ==================== 变更与版本管理 ====================
def _next_version_no(session: Session) -> str:
"""生成批次版本号 P{YYYYMMDD}-{当日序号}(当日自增,两位补零)。"""
today = date.today()
prefix = f"P{today:%Y%m%d}"
seq = 0
for (version_no,) in session.execute(
select(ParamChangeBatch.version_no).where(ParamChangeBatch.version_no.like(f"{prefix}-%"))
):
try:
seq = max(seq, int(version_no.rsplit("-", 1)[1]))
except (IndexError, ValueError): # 手工造数容错:跳过不合规格的版本号
continue
return f"{prefix}-{seq + 1:02d}"
def submit_change(
session: Session,
payload: ParamChangeIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ParamChangeBatchOut:
"""提交变更批次:全量校验 → 建批次 → 逐项写条目(旧值取提交时生效值)。
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 生效日期早于今日 /
同批次重复条目
"""
effective = payload.effective_date if payload.change_type == 2 else date.today()
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PARAM,
action=ACTION_UPDATE,
summary=(
f"提交计提参数变更 {len(payload.items)} 项,"
f"{'立即生效' if payload.change_type == 1 else f'{effective} 起生效'}"
),
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if payload.change_type == 2 and effective < date.today():
raise BadRequestError("指定生效日期不得早于今日")
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
normalized: list[tuple[ParamDefinition, str, str]] = []
seen: set[tuple[str, str]] = set()
for item in payload.items:
definition = param_repository.get_definition_by_code(session, item.param_code)
if definition is None:
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
spec = get_spec(item.param_code)
dimension = item.dimension_value
if spec.dimension_type == "NONE" and dimension != "":
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
if (
spec.dimension_type == "BUSINESS_ROLE"
and dimension
and business_role_repository.get_by_code(session, dimension) is None
):
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
)
if (
spec.dimension_type == "PRODUCT_CATEGORY"
and dimension
and dimension not in PRODUCT_CATEGORIES
):
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
)
value = validate_and_normalize(spec, item.value)
key = (item.param_code, dimension)
if key in seen:
raise BadRequestError(
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
)
seen.add(key)
normalized.append((definition, dimension, value))
try:
batch = param_repository.create_batch(
session,
ParamChangeBatch(
version_no=_next_version_no(session),
effective_date=effective,
change_type=payload.change_type,
remark=payload.remark,
operator_id=operator.id,
operator_name=operator.username,
item_count=len(normalized),
),
)
# 旧值留痕取「本条目生效日时点」的值:存在多个未来生效批次时,
# 每条记录的是它实际顶替的那一版(而非提交时刻的当前值)
for definition, dimension, value in normalized:
old = param_repository.latest_value_before(
session, definition.id, dimension, effective
)
param_repository.create_value(
session,
ParamValue(
param_id=definition.id,
dimension_value=dimension,
effective_date=effective,
value_text=value,
old_value_text=old.value_text if old else None,
batch_id=batch.id,
),
)
session.commit()
except IntegrityError as exc:
# version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员
# 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400
session.rollback()
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
session.commit()
logger.info("提交参数变更 | version_no=%s | items=%s", batch.version_no, batch.item_count)
return ParamChangeBatchOut(
id=batch.id,
version_no=batch.version_no,
effective_date=batch.effective_date,
change_type=batch.change_type,
remark=batch.remark,
operator_name=batch.operator_name,
item_count=batch.item_count,
create_time=batch.create_time,
)
def withdraw_batch(
session: Session, batch_id: int, *, operator: User, meta: RequestMeta | None = None
) -> None:
"""撤回未生效批次。已生效禁撤;条目删除后解析自然回退到更早版本。
:raises NotFoundError: 批次不存在
:raises BadRequestError: 批次已生效
"""
batch = param_repository.get_batch_by_id(session, batch_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PARAM,
action=ACTION_DELETE,
summary=f"撤回参数变更批次 {batch.version_no if batch else f'#{batch_id}'}",
),
meta=meta,
):
if batch is None:
raise NotFoundError("变更批次不存在")
if batch.effective_date <= date.today():
raise BadRequestError("批次已生效,不可撤回")
param_repository.delete_values_by_batch(session, batch.id)
param_repository.delete_batch(session, batch)
session.commit()
logger.info("撤回参数变更批次 | version_no=%s", batch.version_no)
def get_history(
session: Session, param_code: str, query: ParamHistoryQuery
) -> PageResult[ParamHistoryOut]:
"""单参数版本历史(分页,最新在前)。
:raises NotFoundError: 参数不存在
"""
definition = param_repository.get_definition_by_code(session, param_code)
if definition is None:
raise NotFoundError("参数不存在")
rows = param_repository.list_history(
session,
definition.id,
offset=query.offset,
limit=query.page_size,
dimension_value=query.dimension_value,
)
total = param_repository.count_history(
session, definition.id, dimension_value=query.dimension_value
)
return PageResult[ParamHistoryOut](
records=[
ParamHistoryOut(
version_no=batch.version_no,
dimension_value=value.dimension_value,
old_value_text=value.old_value_text,
value_text=value.value_text,
effective_date=value.effective_date,
operator_name=batch.operator_name,
create_time=value.create_time,
)
for value, batch in rows
],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
"""变更批次分页(按创建时间倒序)。"""
batches = param_repository.list_batches(
session,
offset=query.offset,
limit=query.page_size,
start_date=query.start_date,
end_date=query.end_date,
)
total = param_repository.count_batches(
session, start_date=query.start_date, end_date=query.end_date
)
return PageResult[ParamBatchOut](
records=[
ParamBatchOut(
id=batch.id,
version_no=batch.version_no,
effective_date=batch.effective_date,
change_type=batch.change_type,
remark=batch.remark,
operator_name=batch.operator_name,
item_count=batch.item_count,
create_time=batch.create_time,
)
for batch in batches
],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_batch_detail(session: Session, batch_id: int) -> ParamBatchDetailOut:
"""批次明细:批次头 + 逐项旧值 → 新值。
:raises NotFoundError: 批次不存在
"""
batch = param_repository.get_batch_by_id(session, batch_id)
if batch is None:
raise NotFoundError("变更批次不存在")
items = [
ParamBatchItemOut(
param_code=definition.param_code,
param_name=definition.param_name,
dimension_value=value.dimension_value,
old_value_text=value.old_value_text,
value_text=value.value_text,
effective_date=value.effective_date,
)
for value, definition in param_repository.list_values_by_batch(session, batch_id)
]
return ParamBatchDetailOut(
id=batch.id,
version_no=batch.version_no,
effective_date=batch.effective_date,
change_type=batch.change_type,
remark=batch.remark,
operator_name=batch.operator_name,
item_count=batch.item_count,
create_time=batch.create_time,
items=items,
)
# ==================== 对比与预览 ====================
def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDiffOut]:
"""两日期快照逐项对比(全量参数,changed 标记变化)。
:raises BadRequestError: 起始日期晚于截止日期
"""
if from_date > to_date:
raise BadRequestError("起始日期不得晚于截止日期")
old_items = {
(code, dimension): value
for code, dimension, value in resolve_snapshot(session, from_date).iter_items()
}
new_items = {
(code, dimension): value
for code, dimension, value in resolve_snapshot(session, to_date).iter_items()
}
out: list[ParamDiffOut] = []
for code, dimension in sorted(set(old_items) | set(new_items)):
spec = get_spec(code)
old_value = old_items.get((code, dimension))
new_value = new_items.get((code, dimension))
out.append(
ParamDiffOut(
param_code=code,
param_name=spec.name,
param_group=spec.group,
dimension_value=dimension,
old_value=old_value,
new_value=new_value,
changed=old_value != new_value,
)
)
return out
def _preview_rate(
session: Session,
payload: ParamPreviewIn,
snapshot: ParamSnapshot,
days: int,
take,
) -> tuple[Fraction, str, Decimal | None]:
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
"""
if payload.customer_type == "enterprise":
rate = take("enterprise.rate")
return rate, "企业户固定计提比例", None
role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY"
if role_code:
role = business_role_repository.get_by_code(session, role_code)
if role is None:
raise BadRequestError(f"岗位 {role_code!r} 不存在")
template = role.calc_template
if template == "TPL_MANAGER_SHARE":
raise BadRequestError(
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
)
if template == "TPL_INFO_SALES_FIXED":
rate = take("info.rate")
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY":
start = take("salesperson.rate.start")
slope = take("salesperson.rate.slope")
floor = take("salesperson.rate.floor")
boundary = int(take("salesperson.days.boundary"))
rate = formula.salesperson_rate(
days, start=start, slope=slope, floor=floor, boundary_days=boundary
)
return (
rate,
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
None,
)
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
"""计算预览:入参(发货/回款/金额/岗位)→ 公式纯函数 → 出参(含所用参数)。"""
# 参数按回款到账日取值(对照表 #11)
snapshot = resolve_snapshot(session, payload.receipt_date)
revenue = Decimal(payload.revenue)
profit = Decimal(payload.profit)
received = Decimal(payload.received_amount)
used: list[ParamUsedOut] = []
def take(code: str) -> Fraction:
raw = snapshot.get(
code,
role_code=payload.business_role,
product_category=payload.product_category,
)
used.append(
ParamUsedOut(
param_code=code,
value_text=raw,
effective_date=snapshot.effective_date_of(
code,
role_code=payload.business_role,
product_category=payload.product_category,
),
)
)
return parse_fraction(raw)
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
raise BadRequestError(
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
)
days = formula.payment_days(payload.ship_date, payload.receipt_date)
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
profit_rate = formula.profit_rate(profit, revenue)
if base_override is not None:
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
converted = base_override
else:
threshold = take("product.threshold")
converted = formula.converted_profit(revenue, profit_rate, threshold)
performance = formula.performance_amount(converted, rate)
barter_applied = payload.barter
after_barter: Decimal | None = None
if barter_applied:
discount = take("barter.discount")
after_barter = formula.apply_barter(performance, discount)
start_ratio = take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
weight_r = min(100, int(Fraction(received) / Fraction(revenue) * 100)) if revenue > 0 else 0
weight = formula.payment_weight(weight_r)
base_amount = after_barter if barter_applied else performance
payable = to_decimal(Fraction(base_amount) * weight)
probation_note: str | None = None
if payload.probation:
annual = take("probation.annual.threshold")
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
return ParamPreviewOut(
payment_days=days,
rate=str(rate),
rate_text=rate_text,
profit_rate=str(profit_rate),
converted_profit=str(converted),
performance_amount=str(performance),
barter_applied=barter_applied,
performance_after_barter=str(after_barter) if after_barter is not None else None,
start_payment_ok=start_ok,
payment_weight_r=weight_r,
payment_weight=str(weight),
payable_amount=str(payable),
probation_note=probation_note,
params_used=used,
)
# ==================== 导出 ====================
def export_changes(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> bytes:
"""变更记录导出 Excel(openpyxl)。列:版本号/变更人/变更时间/生效日期/参数/维度/旧值/新值。"""
from openpyxl import Workbook
rows = param_repository.list_export_rows(session, start_date=start_date, end_date=end_date)
workbook = Workbook()
sheet = workbook.active
sheet.title = "参数变更记录"
sheet.append(
[
"版本号",
"变更人",
"变更时间",
"生效日期",
"参数编码",
"参数名称",
"维度值",
"旧值",
"新值",
]
)
for batch, value, definition in rows:
sheet.append(
[
batch.version_no,
batch.operator_name,
batch.create_time.strftime("%Y-%m-%d %H:%M:%S"),
value.effective_date.strftime("%Y-%m-%d"),
definition.param_code,
definition.param_name,
value.dimension_value,
value.old_value_text,
value.value_text,
]
)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()

View File

@ -34,7 +34,6 @@ dependencies = [
# 图形验证码(纯 Python,依赖 Pillow)
"captcha>=0.6",
"pillow>=11.0",
"openpyxl>=3.1.5",
]
[dependency-groups]

View File

@ -81,17 +81,6 @@ uv run pytest tests/integration -q # 仅集成
| `integration/test_health.py` | 统一信封与全局异常 | 7 | `/api/health` 始终 200 且依赖状态如实上报;404/405 返回统一信封而非框架默认 `detail` 数组;`/api/docs` 可访问 |
| `integration/test_schema_sync.py` | DDL 不漂移 | 6 | `db/migration/V1__*.sql` 与 `db/init/init-dev.sql` 的**表集合与列集合逐表比对**——两份 DDL 手工同步迟早漂移,且漂移后果隐蔽(开发库结构与生产不一致,上线才发现) |
### M1 计提参数与岗位角色(2026-10-13 完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_business_roles.py` | 岗位 CRUD 链路、内置岗位禁删禁停(种子 3 个)、模板编码校验、编辑换模板、权限 403、模板下拉 5 项 | 8 |
| `tests/integration/test_param_management.py` | 目录 16 项与七分组、企业户 2‰→3‰ 按到账日回溯、斜率 4/360 分数原样存储、变更生命周期(立即/未来生效不泄漏/同日取最新/撤回回退/已生效禁撤)、值校验(上限/枚举/除零分数/过去日期)、预览金标准(27 天=97/10000、折算 15000、绩效 145.5、可付 52.38;361 天=4‰)、经理聚合口径拒绝、权限 403 | 18 |
| `tests/unit/test_formula.py`、`tests/unit/test_param_registry.py` | 八个公式纯函数精确分数断言(第 1 天=899/90000、360=6‰、361=4‰、27 天、防除零、负绩效不归零、R×R)、注册表 16 项与迁移种子同源、模板声明参数闭环 | 43 |
| `tests/unit/test_param_snapshot.py` | 快照覆盖链(角色>产品类别>默认)、注册表默认值兜底、同日取最新、冻结不可变 | 12 |
运行:`uv run pytest -q` → **406 passed**(2026-10-13 全量含回归;T5 冒烟另验证提交/生效/撤回/对比/导出/预览全链路)。
## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 |
@ -125,9 +114,9 @@ uv run pytest tests/integration -q # 仅集成
| 模块 | 状态 | 说明 |
| --- | --- | --- |
| 基础数据管理 | 未开始 | 需求已澄清(2026-10-12);依赖 T+ 对接方式确认 |
| 业务数据管理 | 未开始 | 需求已澄清(28 项裁决见 `docs/问题与解决方案对照表_v2.xlsx`) |
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 |
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
| 报表统计 | 未开始 | 依赖报表口径确认 |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。

View File

@ -62,20 +62,7 @@ class TestSerializeRequestData:
def test_手机号被脱敏(self) -> None:
text = _serialize_request_data({"phone": "13812345678"}) or ""
assert "13812345678" not in text
# phone 键已加入结构化抹除清单(整值 ***)—— 带空格/横线分隔的号码
# (如 138 0013 8000)会穿过分值文本正则,键名整值抹除是更强的防线
assert '"phone": "***"' in text
def test_带分隔符的手机号被脱敏(self) -> None:
"""修复回归:'138 0013 8000' 类分隔符写法曾同时穿过分值正则与键名清单。"""
for payload in (
{"phone": "138 0013 8000"},
{"phone": "138-0013-8000"},
{"mobile": "13800138000"},
):
text = _serialize_request_data(payload) or ""
assert "13800138000" not in text.replace(" ", "").replace("-", "")
assert "***" in text
assert "138****5678" in text
def test_超长中文按字节边界截断且无残字(self) -> None:
"""75000 字节(25000 个汉字)必然超限:

View File

@ -1,131 +0,0 @@
"""公式纯函数测试。
守的是:算钱公式的精确性与边界 —— 一旦按 float 或字面量截断计算,
360/361 天分界、4/360 斜率、27 天回款示例会全部错位,直接影响工资金额。
断言一律用 Fraction/Decimal 精确值,禁近似比较。
"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
from app.services.formula import (
apply_barter,
converted_profit,
payment_days,
payment_weight,
performance_amount,
profit_rate,
salesperson_rate,
start_payment_ok,
)
# 与迁移种子一致的业务员参数(存储口径:start/floor 为 ‰ 数,slope 为 ‰/天)
SP_START = Fraction(10)
SP_SLOPE = Fraction(4, 360)
SP_FLOOR = Fraction(4)
SP_BOUNDARY = 360
def _sp_rate(days: int) -> Fraction:
return salesperson_rate(
days, start=SP_START, slope=SP_SLOPE, floor=SP_FLOOR, boundary_days=SP_BOUNDARY
)
class TestPaymentDays:
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
def test_发货日当天回款为第1天(self) -> None:
d = date(2026, 1, 1)
assert payment_days(d, d) == 1
def test_12月15日发货1月10日回款为27天(self) -> None:
assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27
def test_跨年按自然日连续累计(self) -> None:
assert payment_days(date(2025, 12, 31), date(2026, 1, 1)) == 2
class TestSalespersonRate:
"""业务员计提比例:1~360 天线性递减、≥361 天固定下限(对照表 #4)。"""
def test_第360天恰为千分之6_线性段终点(self) -> None:
assert _sp_rate(360) == Fraction(6, 1000)
def test_第361天固定下限千分之4_分界线(self) -> None:
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
assert _sp_rate(361) == Fraction(4, 1000)
def test_第1天按公式为899比90000_非取整为10(self) -> None:
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000
assert _sp_rate(1) == Fraction(899, 90000)
def test_第180天线性段中点千分之8(self) -> None:
assert _sp_rate(180) == Fraction(8, 1000)
def test_返回值为纯比例_非千分数(self) -> None:
# 绩效 = 折算额 × 比例,比例必须是 0.006 而非 6
assert _sp_rate(360) == Fraction(6, 1000)
assert _sp_rate(360) != Fraction(6)
class TestProfitRate:
"""毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。"""
def test_毛利润率_不含运费(self) -> None:
assert profit_rate(Decimal("45"), Decimal("100")) == Fraction(9, 20)
def test_销售额为零_返回零_防除零(self) -> None:
assert profit_rate(Decimal("0"), Decimal("0")) == Fraction(0)
class TestConvertedProfit:
"""折算毛利润额(化简式):销售额 ×(毛利润率 − 阈值)(对照表 #12/#13)。"""
def test_化简式结果_市政水利30点阈值(self) -> None:
# 100 × (9/20 − 30/100) = 15
assert converted_profit(Decimal("100"), Fraction(9, 20), Fraction(30)) == Decimal("15")
def test_化简式与定义式等价(self) -> None:
# 定义式:毛利润额 / 毛利润率 × (毛利润率 − 阈值) = 45 / (9/20) × (9/20 − 3/10) = 15
revenue, profit = Decimal("100"), Decimal("45")
rate = profit_rate(profit, revenue)
assert converted_profit(revenue, rate, Fraction(30)) == Decimal("15")
def test_毛利润额为零_折算为零_不除零(self) -> None:
assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0")
def test_毛利润率低于阈值_折算为负_不归零(self) -> None:
# 100 × (1/10 − 3/10) = -20(业务确认负值正常计算)
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20")
class TestPerformanceAmount:
"""绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
def test_乘法方向_折算额乘比例(self) -> None:
# 15000 × 97/10000 = 145.5
assert performance_amount(Decimal("15000"), Fraction(97, 10000)) == Decimal("145.5")
def test_负折算额_绩效为负不归零(self) -> None:
assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12")
class TestModifiers:
"""修饰器:以物抵顶、支付权重 R×R、起付判定(对照表 #21/#22/#24)。"""
def test_以物抵顶_按80点折算(self) -> None:
assert apply_barter(Decimal("145.5"), Fraction(80)) == Decimal("116.4")
def test_支付权重_R为60时0点36(self) -> None:
assert payment_weight(60) == Fraction(36, 100)
def test_支付权重_R为100时全额(self) -> None:
assert payment_weight(100) == Fraction(1)
def test_起付判定_达到60点(self) -> None:
assert start_payment_ok(Decimal("60000"), Decimal("100000"), Fraction(60)) is True
def test_起付判定_未达到60点(self) -> None:
assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False

View File

@ -19,10 +19,6 @@ EXPECTED_TABLES = {
"role_permissions",
"login_logs",
"operation_logs",
"business_roles",
"param_definitions",
"param_change_batches",
"param_values",
}

View File

@ -1,154 +0,0 @@
"""参数注册表与公式模板注册表测试。
守的是:注册表是参数种子数据与运行期校验的唯一来源 —— 一旦编码或默认值
与迁移种子漂移,参数解析会取到错误口径;一旦取值校验失守,
畸形值(如 "1/0")会落库并在核算时崩溃或算错钱。
"""
from fractions import Fraction
import pytest
from app.core.exceptions import BadRequestError
from app.services.calc_templates import TEMPLATES, get_template, required_params
from app.services.param_registry import (
PARAM_REGISTRY,
get_spec,
parse_fraction,
validate_and_normalize,
)
# 与 db/migration/V3 迁移种子保持一致的 16 项编码
EXPECTED_CODES = {
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
"manager.share.team",
"manager.share.company",
"enterprise.rate",
"info.rate",
"prepay.rate",
"barter.discount",
"payment.start.ratio",
"payment.weight.mode",
"product.threshold",
"salary.basic.mode",
"probation.annual.threshold",
"probation.salary.basic",
}
class TestRegistry:
"""注册表元数据完整性(与迁移种子同源)。"""
def test_注册表共16项(self) -> None:
assert len(PARAM_REGISTRY) == 16
def test_编码集合与迁移种子一致(self) -> None:
assert set(PARAM_REGISTRY) == EXPECTED_CODES
def test_每项必填字段非空(self) -> None:
for spec in PARAM_REGISTRY.values():
assert spec.code, "code 为空"
assert spec.name, f"{spec.code} 缺中文名称"
assert spec.group, f"{spec.code} 缺分组"
assert spec.value_type in {"DECIMAL", "INT", "BOOL", "ENUM", "TEXT"}
def test_默认值可按类型解析(self) -> None:
for spec in PARAM_REGISTRY.values():
if spec.default_value is None:
continue
if spec.value_type in {"DECIMAL", "INT"}:
parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError
elif spec.value_type == "ENUM":
assert spec.options, f"{spec.code} 为 ENUM 但无选项"
assert spec.default_value in spec.options
def test_取值域上下限可解析(self) -> None:
for spec in PARAM_REGISTRY.values():
if spec.value_min is not None:
parse_fraction(spec.value_min)
if spec.value_max is not None:
parse_fraction(spec.value_max)
def test_取未注册编码抛ValueError_属代码缺陷(self) -> None:
with pytest.raises(ValueError):
get_spec("not.exist.code")
class TestParseFraction:
"""Fraction 精确解析:4/360 不许经 float 中转。"""
def test_分数式精确解析_4除以360(self) -> None:
assert parse_fraction("4/360") == Fraction(1, 90)
def test_整数解析(self) -> None:
assert parse_fraction("10") == Fraction(10)
def test_小数解析(self) -> None:
assert parse_fraction("12.5") == Fraction(25, 2)
def test_除零分数拒绝(self) -> None:
with pytest.raises(BadRequestError):
parse_fraction("1/0")
def test_非数字拒绝(self) -> None:
with pytest.raises(BadRequestError):
parse_fraction("abc")
class TestValidateAndNormalize:
"""参数值校验:类型/取值域/枚举(审查重点 1:畸形值不得落库)。"""
def test_去除首尾空白后返回规范值(self) -> None:
spec = PARAM_REGISTRY["barter.discount"]
assert validate_and_normalize(spec, " 80 ") == "80"
def test_超取值域上限拒绝(self) -> None:
spec = PARAM_REGISTRY["barter.discount"] # value_max=100
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "120")
def test_低于取值域下限拒绝(self) -> None:
spec = PARAM_REGISTRY["barter.discount"] # value_min=0
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "-1")
def test_枚举越界拒绝(self) -> None:
spec = PARAM_REGISTRY["payment.weight.mode"]
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "PARTIAL")
def test_枚举合法值通过(self) -> None:
spec = PARAM_REGISTRY["payment.weight.mode"]
assert validate_and_normalize(spec, "BALANCE") == "BALANCE"
def test_整数类型拒绝小数(self) -> None:
spec = PARAM_REGISTRY["salesperson.days.boundary"]
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "360.5")
def test_分数式斜率原样保留不截断(self) -> None:
spec = PARAM_REGISTRY["salesperson.rate.slope"]
assert validate_and_normalize(spec, "4/360") == "4/360"
class TestTemplates:
"""公式模板注册表:有限集合,声明参数必须真实存在。"""
def test_共五个模板(self) -> None:
assert len(TEMPLATES) == 5
def test_模板声明参数均在注册表内(self) -> None:
for tpl in TEMPLATES.values():
for code in tpl.params:
assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}"
def test_取模板声明参数(self) -> None:
params = required_params("TPL_SALESPERSON_DECAY")
assert "salesperson.rate.slope" in params
def test_未知模板抛ValueError_属代码缺陷(self) -> None:
with pytest.raises(ValueError):
get_template("TPL_NOT_EXIST")

View File

@ -1,95 +0,0 @@
"""参数快照(时点化取值)测试。
守的是:覆盖链与兜底 —— 一旦取错维度(如企业户取到市政阈值行)
或漏兜底,核算会用错口径或直接崩溃;同日多次变更必须取最新留痕。
仓储 SQL 的排序语义(同日取 id 最大)在集成测试中覆盖。
"""
from dataclasses import FrozenInstanceError
from datetime import date
from fractions import Fraction
import pytest
from app.services.param_service import ParamSnapshot
def _snap(rows: list[tuple[str, str, str]]) -> ParamSnapshot:
"""构造快照。rows 须模拟仓储输出:同一 (code, dim) 的最新值在前。"""
# 仓储实际返回 4 元组(含生效日期);单测关注覆盖链,统一补同一个日期
return ParamSnapshot.from_rows(
date(2026, 10, 1), [(code, dim, value, date(2026, 1, 1)) for code, dim, value in rows]
)
class TestCoverChain:
"""覆盖链:角色维度 > 产品类别维度 > 默认行('')。"""
def test_产品类别维度优先于默认行(self) -> None:
snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "municipal_water", "2")])
assert snap.get("enterprise.rate", product_category="municipal_water") == "2"
def test_无匹配维度时落默认行(self) -> None:
snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "municipal_water", "2")])
assert snap.get("enterprise.rate", product_category="rail_transit") == "3"
def test_角色维度优先于产品类别维度(self) -> None:
snap = _snap([("some.param", "municipal_water", "A"), ("some.param", "salesperson", "B")])
got = snap.get("some.param", role_code="salesperson", product_category="municipal_water")
assert got == "B"
def test_无任何维度匹配且无默认行_回退注册表默认值(self) -> None:
snap = _snap([("enterprise.rate", "municipal_water", "2")])
# 轨交无行、无默认行 → 回退注册表 default_value(3)
assert snap.fraction("enterprise.rate", product_category="rail_transit") == Fraction(3)
class TestFallback:
"""注册表默认值兜底(种子缺失时的安全网)。"""
def test_未定义参数按注册表默认值兜底(self) -> None:
snap = _snap([])
assert snap.fraction("prepay.rate") == Fraction(10)
def test_未注册编码抛ValueError_属代码缺陷(self) -> None:
snap = _snap([])
with pytest.raises(ValueError):
snap.fraction("not.registered.code")
class TestSameDayChanges:
"""同日多次变更:仓储 SQL 已按 (effective_date, id) 倒序,快照取先出现者。"""
def test_同日多次变更取最新一条(self) -> None:
snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "", "2")])
assert snap.fraction("enterprise.rate") == Fraction(3)
class TestTypedGetters:
"""类型化取值:Fraction 精确解析、整数、布尔、文本。"""
def test_fraction精确解析分数式(self) -> None:
snap = _snap([("salesperson.rate.slope", "salesperson", "4/360")])
assert snap.fraction("salesperson.rate.slope", role_code="salesperson") == Fraction(1, 90)
def test_integer(self) -> None:
snap = _snap([("salesperson.days.boundary", "salesperson", "360")])
assert snap.integer("salesperson.days.boundary", role_code="salesperson") == 360
def test_text返回枚举原文(self) -> None:
snap = _snap([("payment.weight.mode", "", "FULL")])
assert snap.text("payment.weight.mode") == "FULL"
def test_boolean(self) -> None:
snap = _snap([("some.flag", "", "1")])
assert snap.boolean("some.flag") is True
assert snap.boolean("some.flag") is not False
class TestImmutability:
"""快照冻结:解析结果不得被调用方改写(核算期间一致性)。"""
def test_字段不可赋值(self) -> None:
snap = _snap([])
with pytest.raises(FrozenInstanceError):
snap.biz_date = date(2026, 1, 1) # type: ignore[misc]

23
backend/uv.lock generated
View File

@ -221,15 +221,6 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "fakeredis"
version = "2.39.0"
@ -387,18 +378,6 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
@ -428,7 +407,6 @@ dependencies = [
{ name = "alembic" },
{ name = "captcha" },
{ name = "fastapi" },
{ name = "openpyxl" },
{ name = "pillow" },
{ name = "pwdlib", extra = ["bcrypt"] },
{ name = "pydantic" },
@ -455,7 +433,6 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14" },
{ name = "captcha", specifier = ">=0.6" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pillow", specifier = ">=11.0" },
{ name = "pwdlib", extras = ["bcrypt"], specifier = ">=0.2" },
{ name = "pydantic", specifier = ">=2.9" },

View File

@ -16,10 +16,6 @@
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`;
@ -171,89 +167,6 @@ 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 迁移中的初始化数据一致)
-- ---------------------------------------------------------------------------
@ -261,57 +174,6 @@ 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 成为空操作
-- ---------------------------------------------------------------------------
@ -320,4 +182,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 ('bfbda9c3191d');
INSERT INTO alembic_version (version_num) VALUES ('a45a8087831b');

View File

@ -1,153 +0,0 @@
-- 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';

View File

@ -1,58 +0,0 @@
-- ============================================================================
-- 计提参数查询留档(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;

View File

@ -1,13 +1,11 @@
# 前端
> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/components/layout/BasicLayout.vue`(应用布局与导航)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)。已实现登录链路(2026-10 视觉方向重做:点阵底/涟漪/天光/居中卡片)、系统管理页面、岗位角色与计提参数配置页、Bento 仪表盘首页;视觉体系与设计预览见 `docs/design/视觉方向预览.html`。
> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/components/layout/BasicLayout.vue`(应用布局与导航)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)。当前完成登录链路与系统管理页面(用户/角色/日志查询),业务模块页面待接入。
## 职责
- 登录页与图形验证码交互
- 会话状态管理与路由守卫
- 岗位角色管理页(模板绑定)、计提参数配置页(分组目录/批次提交/版本对比/导出/计算预览)
- 首页 Bento 仪表盘(M1 空态设计;业务上线后接入真实统计,图表用 ECharts)
- 后续:基础数据、业务数据、绩效核算、报表等业务页面
## 技术栈与版本约束
@ -138,13 +136,3 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
**验证码图片不显示** —— 多为代理未生效。访问 `http://localhost:5173/api/health` 应返回统一信封格式 JSON。
**`pnpm install` 报 peer 依赖错误** —— 确认 `@vue/devtools-api` 在 `dependencies` 中(pinia 4 的必需 peer)。
## 端到端测试(2026-10-13 追加 · M1 参数体系)
| 脚本 | 覆盖 | 结果 |
| --- | --- | --- |
| `e2e/login_flow.py` | 登录链路 8 项 | ✓ 全通过(登录页视觉重做后回归) |
| `e2e/system_management.py` | 系统管理 13 项 | ✓ 全通过(布局白化后回归) |
| `e2e/param_management.py` | 计提参数 7 步:目录分组 → 修改入待提交 → 批次提交(立即生效)→ 目录反映新值 → 版本记录 → 恢复种子值 → 计算预览金标准(27 天 / 9.7‰ / 145.5 元) | ✓ 全通过(首跑) |
运行前置:容器 + 后端(8000)+ 前端 dev(5173);`E2E_ADMIN_PASSWORD` 传管理员密码。

View File

@ -1,145 +0,0 @@
"""计提参数管理端到端测试(需要容器、后端、前端都在运行)。
覆盖链路(M1 验收):目录分组展示 → 修改参数入「待提交」→ 批次提交(立即
生效)→ 目录反映新值 → 版本记录可见 → 计算预览金标准复现(27 天 / 9.7‰ /
145.5 元)→ 值改回种子值恢复现场。
运行:``E2E_ADMIN_PASSWORD=... python e2e/param_management.py``
"""
import sys
sys.path.insert(0, __import__("os").path.dirname(__file__))
from playwright.sync_api import sync_playwright
from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
expect_toast,
fail,
login,
wait_toast,
)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
PARAMS_URL = f"{BASE_URL}/system/params"
console_errors: list[str] = []
def step(title: str) -> None:
print(f"—— {title}")
def main() -> int:
failures: list[str] = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1440, "height": 860})
errors = collect_console_errors(page, console_errors)
try:
admin_password = __import__("os").environ.get("E2E_ADMIN_PASSWORD")
if not admin_password:
print("✗ 缺少环境变量 E2E_ADMIN_PASSWORD(管理员密码不入库)")
return 1
login(page, "admin", admin_password)
page.goto(PARAMS_URL, wait_until="networkidle")
step("1. 目录分组展示")
if page.locator(".group-title", has_text="支付环节").count() == 0:
fail("目录分组", "未找到「支付环节」分组")
# 展开「支付环节」分组
page.locator(".el-collapse-item__header", has_text="支付环节").click()
page.wait_for_timeout(300)
step("2. 修改预付款比例 10 → 12(立即生效)")
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first
row.get_by_role("button", name="改", exact=True).click()
dialog = page.locator(".el-dialog")
dialog.locator("input").fill("12")
dialog.get_by_role("button", name="加入待提交").click()
if "待改为 12" not in page.content():
fail("待提交", "参数行未出现「待改为 12」标记")
step("3. 提交批次")
page.get_by_role("button", name="提交变更").click()
page.locator(".el-message-box").get_by_role("button", name="提交").click()
expect_toast(page, "已提交", "提交批次")
step("4. 目录反映新值")
page.wait_for_timeout(500)
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first
if "12" not in row.text_content():
fail("目录生效", "提交后目录未显示新值 12")
step("5. 版本记录可见")
page.get_by_role("button", name="版本管理").click()
page.wait_for_timeout(500)
first_version = page.locator(".el-drawer .el-table tbody tr").first.text_content()
if "P2026" not in first_version:
fail("版本记录", f"最新记录异常:{first_version}")
page.keyboard.press("Escape")
page.wait_for_timeout(300)
step("6. 恢复种子值 10")
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first
row.get_by_role("button", name="改", exact=True).click()
dialog = page.locator(".el-dialog")
dialog.locator("input").fill("10")
dialog.get_by_role("button", name="加入待提交").click()
page.get_by_role("button", name="提交变更").click()
page.locator(".el-message-box").get_by_role("button", name="提交").click()
wait_toast(page, "已提交")
page.wait_for_timeout(400)
step("7. 计算预览金标准(27 天 / 9.7‰ / 145.5)")
page.get_by_role("button", name="计算预览").click()
page.wait_for_timeout(600)
# 多个抽屉组件常驻 DOM(隐藏未销毁),按标题过滤出预览抽屉
drawer = page.locator(".el-drawer").filter(has_text="计算预览").last
# 岗位下拉默认 salesperson(v-model 初始值),无需操作
drawer.locator(".el-form-item", has_text="发货日").locator("input").click()
page.keyboard.type("2025-12-15")
page.keyboard.press("Enter")
drawer.locator(".el-form-item", has_text="回款到账日").locator("input").click()
page.keyboard.type("2026-01-10")
page.keyboard.press("Enter")
drawer.locator(".el-form-item", has_text="销售额").locator("input").fill("100000")
drawer.locator(".el-form-item", has_text="毛利润额").locator("input").fill("45000")
drawer.locator(".el-form-item", has_text="本笔回款").locator("input").fill("60000")
drawer.get_by_role("button", name="试 算").click()
page.wait_for_timeout(900)
result_text = drawer.locator(".preview-result").text_content()
for expected in ["27 天", "145.5", "97/10000"]:
if expected not in result_text:
fail("预览金标准", f"结果缺少 {expected}:{result_text[:200]}")
except E2EFailure as exc:
failures.append(str(exc))
page.screenshot(path="e2e/failure.png")
except Exception as exc: # noqa: BLE001
import traceback
traceback.print_exc()
failures.append(f"未预期异常:{exc}")
page.screenshot(path="e2e/failure.png")
finally:
browser.close()
if failures:
print(f"✗ {failures[0]}")
if console_errors:
print(f" 控制台错误(最近 3 条):{console_errors[-3:]}")
return 1
print("✓ 计提参数管理端到端:7 步全部通过(目录/待提交/提交/生效/版本/恢复/预览金标准)")
if console_errors:
print(f" (控制台非致命消息 {len(console_errors)} 条,见上方日志)")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,90 +0,0 @@
import { request } from './request'
import type { PageData } from './types'
/** 公式模板下拉项(含声明参数,供岗位表单展示) */
export interface TemplateOption {
code: string
name: string
description: string
params: { code: string; name: string }[]
}
/** 岗位角色列表项(对应后端 `BusinessRoleOut`) */
export interface BusinessRoleItem {
id: number
role_code: string
role_name: string
calc_template: string
description: string | null
sort_order: number
status: number
/** 1内置(禁删禁停) 0自定义 */
is_builtin: number
}
export interface BusinessRoleQueryParams {
pageNum?: number
pageSize?: number
keyword?: string
status?: number
}
export interface BusinessRoleCreateParams {
role_code: string
role_name: string
calc_template: string
description?: string
sort_order?: number
}
/** 编辑:不含 role_code(创建后不可修改,编码是参数维度与模板绑定的锚点) */
export interface BusinessRoleUpdateParams {
role_name: string
calc_template: string
description?: string
sort_order?: number
}
/** 分页查询岗位角色(含停用) */
export function listBusinessRoles(
params: BusinessRoleQueryParams,
): Promise<PageData<BusinessRoleItem>> {
return request({ url: '/v1/system/business-roles', method: 'get', params })
}
/** 新增岗位 */
export function createBusinessRole(
data: BusinessRoleCreateParams,
): Promise<BusinessRoleItem> {
return request({ url: '/v1/system/business-roles', method: 'post', data })
}
/** 编辑岗位 */
export function updateBusinessRole(
roleId: number,
data: BusinessRoleUpdateParams,
): Promise<BusinessRoleItem> {
return request({ url: `/v1/system/business-roles/${roleId}`, method: 'put', data })
}
/** 删除岗位(内置岗位后端拒绝,前端同步禁用按钮) */
export function deleteBusinessRole(roleId: number): Promise<null> {
return request({ url: `/v1/system/business-roles/${roleId}`, method: 'delete' })
}
/** 启用 / 禁用(内置岗位后端拒绝停用) */
export function setBusinessRoleStatus(
roleId: number,
status: number,
): Promise<BusinessRoleItem> {
return request({
url: `/v1/system/business-roles/${roleId}/status`,
method: 'put',
data: { status },
})
}
/** 公式模板下拉(含声明参数说明) */
export function listTemplateOptions(): Promise<TemplateOption[]> {
return request({ url: '/v1/system/business-roles/meta/templates', method: 'get' })
}

View File

@ -1,208 +0,0 @@
import service, { request } from './request'
import type { PageData } from './types'
/** 参数目录项(含各维度截至查询时点的生效值) */
export interface ParamDimensionValue {
dimension_value: string
value_text: string
effective_date: string | null
}
export interface ParamCatalogItem {
param_code: string
param_name: string
param_group: string
value_type: string
unit: string | null
dimension_type: string
options: string[]
default_value: string | null
value_min: string | null
value_max: string | null
description: string | null
values: ParamDimensionValue[]
}
/** 参数目录(asOf 缺省为今日) */
export function getCatalog(asOf?: string): Promise<ParamCatalogItem[]> {
return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} })
}
/** 变更条目:参数 + 维度 + 新值 */
export interface ParamChangeItem {
param_code: string
dimension_value: string
value: string
}
export interface ParamChangePayload {
/** 生效方式:1立即生效 2指定日期生效 */
change_type: number
effective_date?: string
remark?: string
items: ParamChangeItem[]
}
export interface ParamChangeBatch {
id: number
version_no: string
effective_date: string
change_type: number
remark: string | null
operator_name: string
item_count: number
create_time: string
}
/** 提交变更批次 */
export function submitChanges(data: ParamChangePayload): Promise<ParamChangeBatch> {
return request({ url: '/v1/system/params/changes', method: 'post', data })
}
export interface ParamBatchItem {
param_code: string
param_name: string
dimension_value: string
old_value_text: string | null
value_text: string
effective_date: string
}
export interface ParamBatchDetail {
id: number
version_no: string
effective_date: string
change_type: number
remark: string | null
operator_name: string
item_count: number
create_time: string
items: ParamBatchItem[]
}
/** 批次明细(旧值 → 新值) */
export function getBatchDetail(batchId: number): Promise<ParamBatchDetail> {
return request({ url: `/v1/system/params/changes/${batchId}`, method: 'get' })
}
export interface ParamBatch {
id: number
version_no: string
effective_date: string
change_type: number
remark: string | null
operator_name: string
item_count: number
create_time: string
}
/** 变更记录分页 */
export function listBatches(params: {
pageNum?: number
pageSize?: number
start_date?: string
end_date?: string
}): Promise<PageData<ParamBatch>> {
return request({ url: '/v1/system/params/changes', method: 'get', params })
}
/** 撤回未生效批次 */
export function withdrawBatch(batchId: number): Promise<null> {
return request({ url: `/v1/system/params/changes/${batchId}`, method: 'delete' })
}
export interface ParamDiff {
param_code: string
param_name: string
param_group: string
dimension_value: string
old_value: string | null
new_value: string | null
changed: boolean
}
/** 两日期快照对比 */
export function compareParams(fromDate: string, toDate: string): Promise<ParamDiff[]> {
return request({
url: '/v1/system/params/compare',
method: 'get',
params: { from_date: fromDate, to_date: toDate },
})
}
export interface ParamHistory {
version_no: string
dimension_value: string
old_value_text: string | null
value_text: string
effective_date: string
operator_name: string
create_time: string
}
/** 单参数版本历史 */
export function getParamHistory(
paramCode: string,
params: { pageNum?: number; pageSize?: number; dimension_value?: string },
): Promise<PageData<ParamHistory>> {
return request({ url: `/v1/system/params/${paramCode}/history`, method: 'get', params })
}
/**
* 导出变更记录(文件流)。
*
* 走 axios 实例(带令牌)而非 request 封装:响应是 xlsx 文件流,
* 不是统一信封,拦截器已对 responseType=blob 直通。
*/
export async function exportChanges(params: {
start_date?: string
end_date?: string
}): Promise<Blob> {
const response = await service.get('/v1/system/params/export', {
params,
responseType: 'blob',
})
return response.data as Blob
}
/** 计算预览入参(金额为字符串,Decimal 承载) */
export interface ParamPreviewPayload {
business_role?: string
customer_type?: string
product_category?: string
ship_date: string
receipt_date: string
revenue: string
profit: string
received_amount: string
barter: boolean
probation: boolean
}
export interface ParamUsed {
param_code: string
value_text: string
effective_date: string | null
}
export interface ParamPreviewResult {
payment_days: number
rate: string
rate_text: string
profit_rate: string
converted_profit: string
performance_amount: string
barter_applied: boolean
performance_after_barter: string | null
start_payment_ok: boolean
payment_weight_r: number
payment_weight: string
payable_amount: string
probation_note: string | null
params_used: ParamUsed[]
}
/** 计算预览 */
export function previewCalc(data: ParamPreviewPayload): Promise<ParamPreviewResult> {
return request({ url: '/v1/system/params/preview', method: 'post', data })
}

View File

@ -51,10 +51,6 @@ function handleUnauthorized(message: string): void {
service.interceptors.response.use(
(response: AxiosResponse<ApiResult>) => {
// 文件流(导出下载)不是统一信封,直通给调用方处理
if (response.config.responseType === 'blob') {
return response
}
const body = response.data
if (body.code !== 200) {
// 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况
@ -65,19 +61,7 @@ service.interceptors.response.use(
},
(error: AxiosError<ApiResult>) => {
const status = error.response?.status
let message = error.response?.data?.message ?? '网络异常,请稍后重试'
// blob 请求失败时 data 是 Blob(如导出接口返回的 JSON 信封错误),需读出真实 message
if (error.response?.data instanceof Blob) {
message = '导出失败,请稍后重试'
void error.response.data.text().then((text) => {
try {
const body = JSON.parse(text) as { message?: string }
if (body.message) ElMessage.error(body.message)
} catch {
/* 非 JSON 响应,保持兜底提示 */
}
})
}
const message = error.response?.data?.message ?? '网络异常,请稍后重试'
if (status === 401) {
// 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403,

View File

@ -2,49 +2,32 @@
:root {
/*
* 品牌色(2026-10-12 视觉方向:企业级扁平 · 轻质感 · 靛蓝点缀)。
* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。
* 设计预览与规范:docs/design/视觉方向预览.html
* 品牌色:与登录页视觉体系同一套(原为登录页 scoped 局部变量,布局框架
* 与系统管理页面共用后提升为全局 token)。
* 金色分两档:亮金只用于深色底、深金只用于浅色底,保证两处对比度都达标。
*/
--ys-indigo: #2e4ba8;
--ys-indigo-deep: #223a85;
--ys-indigo-wash: #edf1fa;
--ys-paper: #ffffff;
--ys-mist: #f4f5f8;
--ys-line: rgba(28, 35, 51, 0.09);
--ys-ink: #1c2333;
--ys-ink-2: #5b6577;
--ys-ink-3: #9aa3b2;
--ys-ink-900: #061a22;
--ys-ink-800: #0a2833;
--ys-ink-700: #0f3646;
--ys-ink-600: #1a4f63;
--ys-gold-bright: #c9a227;
--ys-gold-deep: #8f6520;
--ys-paper: #fafaf7;
--ys-text: #16262d;
--ys-muted: #6b7b84;
/*
* 旧 token 名保留映射(BasicLayout / 登录页存量引用,值迁移到新体系:
* 深水青 → 冷墨系、金色 → 靛蓝系),存量页面迁移完成后移除。
*/
--ys-ink-900: #16203a;
--ys-ink-800: #1c2333;
--ys-ink-700: #242e4a;
--ys-ink-600: #3a4a6e;
--ys-gold-bright: #6d81c2;
--ys-gold-deep: #2e4ba8;
--ys-text: #1c2333;
--ys-muted: #5b6577;
/* 品牌字:思源黑体(实施时自托管 4 字子集,内网可用) */
--ys-font-brand: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
/*
* Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。
* Element Plus 主色 = 赭金(浅色底配白字约 6.2:1)。
* 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 ——
* 不用 color-mix(),避免依赖较新的浏览器特性。
*/
--el-color-primary: #2e4ba8;
--el-color-primary-light-3: #6d81c2;
--el-color-primary-light-5: #97a5d4;
--el-color-primary-light-7: #c0c9e5;
--el-color-primary-light-8: #d5dbee;
--el-color-primary-light-9: #eaedf6;
--el-color-primary-dark-2: #253c86;
--el-color-primary: #8f6520;
--el-color-primary-light-3: #b19363;
--el-color-primary-light-5: #c7b290;
--el-color-primary-light-7: #ddd1bc;
--el-color-primary-light-8: #e9e0d2;
--el-color-primary-light-9: #f4f0e9;
--el-color-primary-dark-2: #72511a;
}
* {
@ -63,8 +46,8 @@ body {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', Arial, sans-serif;
font-size: 14px;
color: var(--ys-ink);
background-color: var(--ys-mist);
color: #303133;
background-color: #f5f7fa;
-webkit-font-smoothing: antialiased;
}

View File

@ -24,42 +24,26 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
<style scoped>
/*
* 中性灰侧栏上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
* 深色底上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
* 后者与组件库规则同权重,受样式注入顺序影响而不稳定。
* 激活项 = 白底浮起(微渐变 + 内高光 + 分层阴影)+ 左侧 2px 靛蓝条。
* 激活色用亮金(深色底专用档,见 index.css 的金色两档约定)。
*/
.app-menu {
--el-menu-bg-color: transparent;
--el-menu-text-color: var(--ys-ink-2);
--el-menu-active-color: var(--ys-indigo);
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.6);
--el-menu-item-height: 40px;
--el-menu-text-color: rgb(250 250 247 / 78%);
--el-menu-active-color: var(--ys-gold-bright);
--el-menu-hover-bg-color: rgb(255 255 255 / 6%);
--el-menu-item-height: 44px;
border-right: none;
}
.app-menu :deep(.el-menu-item.is-active) {
position: relative;
background: linear-gradient(180deg, #ffffff, #fbfcfe);
box-shadow:
inset 0 1px 0 #fff,
0 1px 2px rgba(28, 35, 51, 0.06),
0 4px 10px rgba(28, 35, 51, 0.06);
font-weight: 500;
}
.app-menu :deep(.el-menu-item.is-active)::before {
content: '';
position: absolute;
left: -14px;
top: 9px;
bottom: 9px;
width: 2px;
border-radius: 1px;
background: var(--ys-indigo);
background-color: rgb(201 162 39 / 12%);
font-weight: 600;
}
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
color: var(--ys-indigo);
color: var(--ys-gold-bright);
}
</style>

View File

@ -42,7 +42,6 @@ async function handleLogout(): Promise<void> {
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__line"></span>
</div>
<AppMenu class="app-aside__menu" />
</el-aside>
@ -97,7 +96,6 @@ async function handleLogout(): Promise<void> {
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__line"></span>
</div>
<AppMenu />
</el-drawer>
@ -112,39 +110,27 @@ async function handleLogout(): Promise<void> {
.app-aside {
display: flex;
flex-direction: column;
/* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
border-right: 1px solid var(--ys-line);
background: linear-gradient(180deg, var(--ys-ink-900) 0%, var(--ys-ink-700) 100%);
}
.app-brand {
display: flex;
flex-direction: column;
gap: 3px;
padding: 18px 20px 12px;
gap: 2px;
padding: 18px 20px 14px;
}
.app-brand__name {
font-family: var(--ys-font-brand);
font-size: 16px;
font-weight: 500;
letter-spacing: 4px;
color: var(--ys-ink);
font-family: 'Songti SC', SimSun, serif;
font-size: 18px;
letter-spacing: 2px;
color: var(--ys-paper);
}
.app-brand__sub {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--ys-ink-3);
}
/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款) */
.app-brand__line {
width: 24px;
height: 2px;
border-radius: 1px;
margin-top: 8px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
font-size: 12px;
letter-spacing: 1px;
color: rgb(250 250 247 / 55%);
}
.app-aside__menu {
@ -157,8 +143,8 @@ async function handleLogout(): Promise<void> {
align-items: center;
justify-content: space-between;
height: 56px;
background: linear-gradient(180deg, #ffffff, #fdfdfe);
border-bottom: 1px solid var(--ys-line);
background-color: #fff;
border-bottom: 1px solid #e5e7eb;
}
.app-header__left {
@ -187,7 +173,7 @@ async function handleLogout(): Promise<void> {
}
.app-header__menu-toggle:focus-visible {
outline: 2px solid var(--ys-indigo);
outline: 2px solid var(--ys-gold-deep);
outline-offset: 2px;
}
@ -204,10 +190,7 @@ async function handleLogout(): Promise<void> {
.app-main {
padding: 20px;
/* 雾灰底 + 点阵网格:内容区的秩序与密度 */
background-color: var(--ys-mist);
background-image: radial-gradient(rgba(28, 35, 51, 0.05) 1px, transparent 1px);
background-size: 24px 24px;
background-color: #f5f7fa;
}
@media (max-width: 768px) {
@ -228,9 +211,9 @@ async function handleLogout(): Promise<void> {
</style>
<style>
/* 抽屉内容铺满侧栏同款灰(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
/* 抽屉内容铺满深色底(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
.app-drawer .el-drawer__body {
padding: 0;
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
background: linear-gradient(180deg, var(--ys-ink-900) 0%, var(--ys-ink-700) 100%);
}
</style>

View File

@ -52,23 +52,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/roles/index.vue'),
meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 },
},
{
path: '/system/business-roles',
name: 'system-business-roles',
component: () => import('@/views/system/business-roles/index.vue'),
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
},
{
path: '/system/params',
name: 'system-params',
component: () => import('@/views/system/params/index.vue'),
meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 },
},
{
path: '/system/logs',
name: 'system-logs',
component: () => import('@/views/system/logs/index.vue'),
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 },
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 4 },
},
],
},

View File

@ -7,7 +7,7 @@ export const ROLE_ADMIN = 'admin'
* 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的
* `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。
*/
export const MODULE_OPTIONS = ['用户管理', '角色管理', '岗位角色', '计提参数'] as const
export const MODULE_OPTIONS = ['用户管理', '角色管理'] as const
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const
/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */

View File

@ -1,350 +1,49 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as businessRoleApi from '@/api/businessRoles'
import * as paramsApi from '@/api/params'
import { handledError } from '@/utils/error'
const router = useRouter()
import { useUserStore } from '@/store/user'
import { formatDateTime } from '@/utils/format'
/**
* 首页仪表盘(Bento Grid,2026-10-12 视觉方向)。
* 首页:登录链路验证页。
*
* M1 阶段:计提参数与岗位配置已就绪(真实数据);绩效/回款/待办等业务卡
* 展示空态 —— 业务数据管理上线后接入真实统计(图表用 ECharts 渲染)。
* 空态文案遵循全局规范:空态是邀请,说明「这里将出现什么、怎么让它出现」。
* 顶部栏(用户信息、退出登录)已由 BasicLayout 提供,本页只保留内容区;
* 用户名等字段的展示是端到端脚本的断言对象,调整时需同步 e2e。
*/
const roleCount = ref<number | null>(null)
const paramCount = ref<number | null>(null)
const latestVersion = ref<string | null>(null)
onMounted(() => {
// 三项统计各自容错:一张卡失败不影响其余卡片
void (async () => {
try {
const page = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 1 })
roleCount.value = page.total
} catch (error) {
handledError(error)
}
})()
void (async () => {
try {
const catalog = await paramsApi.getCatalog()
paramCount.value = catalog.length
} catch (error) {
handledError(error)
}
})()
void (async () => {
try {
const page = await paramsApi.listBatches({ pageNum: 1, pageSize: 1 })
latestVersion.value = page.list[0]?.version_no ?? null
} catch (error) {
handledError(error)
}
})()
})
const userStore = useUserStore()
</script>
<template>
<div>
<div class="page-head">
<h1 class="page-title">仪表盘</h1>
<p class="page-desc">系统概览 · 计提参数与岗位配置就绪,业务数据管理上线后接入核算统计</p>
<span class="waterline"></span>
</div>
<el-card shadow="never">
<template #header>
<span>登录状态验证通过</span>
</template>
<div class="bento">
<!-- 绩效总额:业务上线前的空态 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">本月绩效总额</div>
<div class="b-empty">
<div class="b-empty-title">暂无核算数据</div>
<div class="b-empty-desc">回款录入并核算后,这里将显示本月绩效总额与趋势</div>
</div>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="用户 ID">{{ userStore.userInfo?.id }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ userStore.userInfo?.username }}</el-descriptions-item>
<el-descriptions-item label="姓名">
{{ userStore.userInfo?.real_name || '—' }}
</el-descriptions-item>
<el-descriptions-item label="角色">
{{ userStore.roles.join('、') || '—' }}
</el-descriptions-item>
<el-descriptions-item label="上次登录">
{{ formatDateTime(userStore.userInfo?.last_login_time) }}
</el-descriptions-item>
</el-descriptions>
<!-- 回款趋势:业务上线前的空态 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">近 6 个月回款</div>
<div class="b-empty">
<div class="b-empty-title">暂无回款数据</div>
<div class="b-empty-desc">业务数据管理上线后,此处展示按月回款趋势</div>
</div>
</div>
<!-- 待办 -->
<div class="bento-card b-2x1">
<div class="b-eyebrow">待办</div>
<div class="b-empty-mini">业务数据管理上线后,待核算回款会出现在这里</div>
</div>
<!-- 营销人员:人员管理上线后接入 -->
<div class="bento-card">
<div class="b-eyebrow">营销人员</div>
<div class="b-empty-mini">人员管理上线后同步</div>
</div>
<!-- 岗位角色:真实数据 -->
<div class="bento-card">
<div class="b-eyebrow">岗位角色</div>
<div class="b-num">{{ roleCount ?? '—' }}<small>个</small></div>
<button class="b-link" type="button" @click="router.push('/system/business-roles')">
管理 ›
</button>
</div>
<!-- 计提参数:真实数据 -->
<div class="bento-card">
<div class="b-eyebrow">计提参数</div>
<div class="b-num">{{ paramCount ?? '—' }}<small>项</small></div>
<button class="b-link" type="button" @click="router.push('/system/params')">
维护 ›
</button>
</div>
<!-- 常用操作 -->
<div class="bento-card b-2x1">
<div class="b-eyebrow">常用操作</div>
<div class="b-links">
<button class="b-op" type="button" @click="router.push('/system/params')">
计提参数
</button>
<button class="b-op" type="button" @click="router.push('/system/business-roles')">
岗位角色
</button>
<button class="b-op" type="button" @click="router.push('/system/logs')">
操作日志
</button>
<span class="b-op b-op-disabled" title="业务数据管理上线后开放">录入回款</span>
</div>
</div>
<!-- T+ 同步 -->
<div class="bento-card">
<div class="b-eyebrow">T+ 同步</div>
<div class="b-state">待接入</div>
<div class="b-sub">每周一次 · 手动触发</div>
</div>
</div>
</div>
<el-alert
class="dashboard-hint"
type="info"
:closable="false"
show-icon
title="这是登录链路的验证页"
description="刷新页面可验证会话恢复(守卫会调用 /me);「系统管理」菜单已接入用户管理、角色管理与日志查询。"
/>
</el-card>
</template>
<style scoped>
.page-head {
margin-bottom: 18px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.page-desc {
margin: 4px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
}
.waterline {
display: block;
width: 56px;
height: 2px;
margin-top: 10px;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
/* Bento Grid:4 列基格,卡片按重要性混排大小 */
.bento {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 102px;
gap: 14px;
}
.bento-card {
display: flex;
flex-direction: column;
padding: 17px 20px;
overflow: hidden;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: 10px;
box-shadow:
0 1px 2px rgba(28, 35, 51, 0.04),
0 3px 10px rgba(28, 35, 51, 0.03);
transition:
transform 180ms cubic-bezier(0.25, 0.1, 0.25, 1),
box-shadow 180ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
.bento-card:hover {
transform: translateY(-2px);
box-shadow:
0 1px 2px rgba(28, 35, 51, 0.05),
0 6px 18px rgba(28, 35, 51, 0.07);
}
.b-2x2 {
grid-column: span 2;
grid-row: span 2;
}
.b-2x1 {
grid-column: span 2;
}
.b-eyebrow {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--ys-ink-2);
}
.b-num {
margin-top: auto;
font-size: 24px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.b-num small {
margin-left: 3px;
font-size: 12px;
font-weight: 400;
color: var(--ys-ink-3);
}
.b-sub {
margin-top: 3px;
font-size: 11px;
color: var(--ys-ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b-state {
margin-top: auto;
font-size: 15px;
font-weight: 600;
color: var(--ys-ink-2);
}
.b-empty {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.b-empty-title {
font-size: 14px;
font-weight: 500;
color: var(--ys-ink-2);
}
.b-empty-desc {
max-width: 260px;
margin-top: 6px;
font-size: 12px;
color: var(--ys-ink-3);
line-height: 1.7;
}
.b-empty-mini {
margin-top: auto;
margin-bottom: auto;
font-size: 12px;
color: var(--ys-ink-3);
line-height: 1.7;
}
.b-links {
display: flex;
gap: 8px;
align-items: center;
margin-top: auto;
}
.b-link {
padding: 0;
margin-top: 6px;
font-size: 12px;
color: var(--ys-indigo);
text-align: left;
cursor: pointer;
background: none;
border: none;
}
.b-link:hover {
color: var(--ys-indigo-deep);
}
.b-op {
height: 30px;
padding: 0 13px;
font-size: 12px;
font-family: inherit;
color: var(--ys-ink);
cursor: pointer;
background: linear-gradient(180deg, #ffffff, #fbfcfe);
border: 1px solid var(--ys-line);
border-radius: 7px;
transition:
color 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
border-color 150ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
button.b-op:hover {
color: var(--ys-indigo);
border-color: rgba(46, 75, 168, 0.45);
}
.b-op-disabled {
color: var(--ys-ink-3);
cursor: not-allowed;
}
@media (max-width: 1024px) {
.bento {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.bento {
grid-template-columns: 1fr;
grid-auto-rows: auto;
}
.bento-card {
min-height: 90px;
}
.b-2x2,
.b-2x1 {
grid-column: span 1;
grid-row: span 1;
min-height: 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.bento-card {
transition: none;
}
.dashboard-hint {
margin-top: 18px;
}
</style>

View File

@ -77,29 +77,53 @@ onMounted(refreshCaptcha)
<template>
<div class="login">
<!-- 背景装饰:右上涟漪同心圆(「衍水」品牌意象);顶部天光由 .login::before 承担 -->
<div class="login-ripple" aria-hidden="true">
<i></i><i></i><i></i><i></i>
</div>
<!-- 品牌面板:以「水」为意象 —— 公司名衍水,业务是流水之账 -->
<section class="brand">
<!-- 水纹层理:越往下越密,如潜向深处。纯装饰,对读屏隐藏 -->
<svg class="brand-flow" viewBox="0 0 600 1000" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<path class="brand-flow__line" style="--i: 0" d="M-40,150 C 150,95 260,205 420,155 S 620,95 660,120" />
<path class="brand-flow__line" style="--i: 1" d="M-40,265 C 170,200 300,320 460,255 S 630,190 660,215" />
<path class="brand-flow__line" style="--i: 2" d="M-40,372 C 160,318 320,420 470,360 S 640,300 660,320" />
<path class="brand-flow__line" style="--i: 3" d="M-40,470 C 180,428 310,510 480,462 S 645,410 660,425" />
<path class="brand-flow__line" style="--i: 4" d="M-40,562 C 150,532 330,596 470,560 S 640,520 660,530" />
<path class="brand-flow__line" style="--i: 5" d="M-40,650 C 190,626 300,678 490,648 S 650,618 660,624" />
<path class="brand-flow__line" style="--i: 6" d="M-40,732 C 160,714 320,752 460,730 S 640,708 660,712" />
<path class="brand-flow__line" style="--i: 7" d="M-40,810 C 175,798 305,826 485,808 S 645,792 660,795" />
<path class="brand-flow__line" style="--i: 8" d="M-40,882 C 155,873 330,894 470,880 S 645,868 660,870" />
<path class="brand-flow__line" style="--i: 9" d="M-40,946 C 185,939 300,955 490,944 S 650,934 660,935" />
</svg>
<div class="login-wrap">
<div class="login-card">
<div class="card-brand">
<div class="card-brand__name">衍水集团</div>
<p class="card-brand__slogan">把每一笔回款,算成一份踏实。</p>
<span class="waterline"></span>
</div>
<!-- 品牌内容整体居中成块。此前用 space-between 把品牌与标语顶到两端,
中间空出大片,两处都成了孤岛;聚成一块才有构图 -->
<div class="brand-inner">
<div class="brand-block">
<h1 class="brand-name">衍水集团</h1>
<hr class="brand-rule" />
<p class="brand-system">营销人员工资管理系统</p>
<div class="card-head">
<h2 class="card-title">欢迎回来</h2>
<p class="card-hint">请输入账号密码进入系统</p>
<!-- 断行位置显式控制:中文可在任意字间断行,交给浏览器自动换行
会把「楚」这样的单字挤到末行 -->
<p class="brand-hero">
<span>每一笔回款,</span>
<span>都算得清楚</span>
</p>
<p class="brand-stages">回款 · 计提 · 核算 · 发放</p>
</div>
</div>
</section>
<!-- 表单面板 -->
<section class="panel">
<div class="panel-inner">
<h2 class="panel-title">登录</h2>
<p class="panel-hint">请使用管理员分配的账号</p>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
size="large"
@keyup.enter="handleSubmit"
>
<el-form-item label="用户名" prop="username">
@ -149,149 +173,175 @@ onMounted(refreshCaptcha)
</el-button>
</el-form-item>
</el-form>
</div>
<p class="login-foot">© 衍水集团 · 内部系统</p>
</div>
<p class="panel-foot">衍水集团 · 内部系统</p>
</div>
</section>
</div>
</template>
<style scoped>
/* 视觉体系(2026-10-12 确认):企业级扁平 · 轻质感 · 品牌色点缀 · 微动效 */
/*
* 配色:品牌 token 已提升为全局(见 assets/styles/index.css,布局框架与
* 系统管理页面共用),此处只做局部别名,用法与取值均与原先一致。
*/
.login {
--ink-900: var(--ys-ink-900);
--ink-800: var(--ys-ink-800);
--ink-700: var(--ys-ink-700);
--ink-600: var(--ys-ink-600);
--gold-bright: var(--ys-gold-bright);
--gold-deep: var(--ys-gold-deep);
--paper: var(--ys-paper);
--text: var(--ys-text);
--muted: var(--ys-muted);
display: flex;
min-height: 100vh;
}
/* ============ 品牌面板 ============ */
.brand {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 40px 24px;
flex: 1 1 56%;
overflow: hidden;
background-color: #f2f4f8;
/* 点阵网格:秩序与密度 */
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
background-size: 24px 24px;
background: linear-gradient(158deg, var(--ink-800) 0%, var(--ink-900) 62%, #04141b 100%);
}
/* 顶部天光:靛蓝微光从上方洒下,给页面空气感 */
.login::before {
content: '';
position: absolute;
top: -180px;
left: 50%;
width: 900px;
height: 480px;
transform: translateX(-50%);
pointer-events: none;
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.09), rgba(46, 75, 168, 0));
}
/* 右上涟漪:同心圆水波,部分出画 */
.login-ripple {
position: absolute;
top: -190px;
right: -150px;
width: 560px;
height: 560px;
pointer-events: none;
}
.login-ripple i {
/* 水下的光:给居中的内容一个落点,否则深色面板会显得空 */
.brand::before {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(46, 75, 168, 0.1);
content: '';
background: radial-gradient(58% 46% at 32% 46%, rgb(32 116 146 / 26%), transparent 72%);
pointer-events: none;
}
.login-ripple i:nth-child(2) {
inset: 70px;
border-color: rgba(46, 75, 168, 0.075);
.brand-flow {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.login-ripple i:nth-child(3) {
inset: 140px;
border-color: rgba(46, 75, 168, 0.055);
.brand-flow__line {
fill: none;
stroke: var(--ink-600);
stroke-width: 1;
opacity: 0.55;
/* 逐条画出:一个统一的入场动作,而非散落特效 */
stroke-dasharray: 1200;
stroke-dashoffset: 1200;
animation: flow-draw 2.4s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
animation-delay: calc(var(--i) * 0.13s);
}
.login-ripple i:nth-child(4) {
inset: 210px;
border: none;
background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015));
}
.login-wrap {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
/* 登录卡:纸白微渐变 + 半透明描边 + 分层阴影 + 入场上浮 */
.login-card {
width: 400px;
max-width: 100%;
padding: 30px 36px 32px;
overflow: hidden;
animation: card-rise 320ms cubic-bezier(0.25, 0.1, 0.25, 1) backwards;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid rgba(28, 35, 51, 0.09);
border-radius: 14px;
box-shadow:
0 2px 4px rgba(28, 35, 51, 0.04),
0 16px 40px rgba(28, 35, 51, 0.1);
}
@keyframes card-rise {
from {
opacity: 0;
transform: translateY(8px);
@keyframes flow-draw {
to {
stroke-dashoffset: 0;
}
}
.card-brand {
text-align: center;
.brand-inner {
position: relative;
z-index: 1;
display: flex;
align-items: center;
width: 100%;
padding: 56px clamp(40px, 6vw, 104px);
}
.card-brand__name {
font-family: var(--ys-font-brand);
font-size: 17px;
font-weight: 500;
letter-spacing: 0.34em;
color: var(--ys-ink);
/* 内容成块并限宽:超宽屏下文字块不会被拉散 */
.brand-block {
max-width: 30rem;
}
.card-brand__slogan {
margin-top: 8px;
font-size: 12.5px;
letter-spacing: 0.02em;
color: var(--ys-ink-3);
}
/* 签名元素:渐隐靛蓝水线 */
.waterline {
display: block;
width: 40px;
height: 2px;
margin: 14px auto 0;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.card-head {
margin-top: 26px;
}
.card-title {
.brand-name {
margin: 0;
font-size: 15px;
font-family: 'Source Han Serif SC', 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
font-size: clamp(36px, 3.6vw, 54px);
font-weight: 600;
color: var(--ys-ink);
line-height: 1.15;
letter-spacing: 0.2em;
color: #f2f6f7;
}
.card-hint {
margin: 4px 0 0;
.brand-rule {
width: 52px;
height: 2px;
margin: 24px 0 18px;
background-color: var(--gold-bright);
border: 0;
}
.brand-system {
margin: 0;
font-size: clamp(13px, 1.1vw, 16px);
font-weight: 300;
letter-spacing: 0.28em;
color: #9db6c0;
}
.brand-hero {
margin: 58px 0 0;
font-size: clamp(16px, 1.4vw, 21px);
font-weight: 400;
line-height: 1.8;
letter-spacing: 0.06em;
color: #d9e5ea;
}
.brand-hero span {
display: block;
}
/* 三个业务环节:用间隔点连成一行,比带框的标签轻得多,
也不会被误读成可点击的按钮 */
.brand-stages {
margin: 18px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
letter-spacing: 0.24em;
color: #6f8b97;
}
/* ============ 表单面板 ============ */
.panel {
display: flex;
flex: 1 1 44%;
align-items: center;
justify-content: center;
padding: 48px clamp(28px, 5vw, 80px);
background-color: var(--paper);
}
.panel-inner {
width: 100%;
max-width: 356px;
animation: panel-rise 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) 0.35s both;
}
@keyframes panel-rise {
from {
opacity: 0;
transform: translateY(14px);
}
}
.panel-title {
margin: 0 0 6px;
font-size: 25px;
font-weight: 600;
letter-spacing: 0.16em;
color: var(--text);
}
.panel-hint {
margin: 0 0 30px;
font-size: 13px;
color: var(--muted);
}
.captcha {
@ -320,15 +370,14 @@ onMounted(refreshCaptcha)
padding: 0;
overflow: hidden;
cursor: pointer;
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
border: 1px solid var(--ys-line);
border-radius: 7px;
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04);
transition: border-color 150ms var(--ys-ease, ease);
background-color: #fff;
border: 1px solid #dcdfe6;
border-radius: 4px;
transition: border-color 0.2s;
}
.captcha__image:hover {
border-color: var(--ys-indigo);
border-color: var(--gold-deep);
}
.captcha__image img {
@ -340,54 +389,89 @@ onMounted(refreshCaptcha)
.captcha__placeholder {
font-size: 12px;
color: var(--ys-ink-3);
color: var(--muted);
}
.form-actions :deep(.el-form-item__content) {
margin-top: 4px;
}
/* 覆盖 Element Plus 的按钮变量而非直接写 background-color ——
后者与 .el-button--primary 同权重,会受样式注入顺序影响而不稳定 */
.submit {
width: 100%;
letter-spacing: 0.4em;
--el-button-bg-color: var(--gold-deep);
--el-button-border-color: var(--gold-deep);
--el-button-hover-bg-color: #a3762a;
--el-button-hover-border-color: #a3762a;
--el-button-active-bg-color: #7d5719;
--el-button-active-border-color: #7d5719;
}
.login-foot {
margin: 16px 0 0;
font-size: 11px;
letter-spacing: 0.08em;
color: var(--ys-ink-3);
.panel-foot {
margin: 32px 0 0;
font-size: 12px;
text-align: center;
letter-spacing: 0.1em;
color: #9aa8af;
}
/* 输入聚焦落在体系主色上(Element Plus 主色已随全局 token 换为靛蓝) */
.login :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px rgba(28, 35, 51, 0.13) inset;
/* 输入框聚焦态也落在同一套配色上 */
.panel :deep(.el-input__wrapper) {
--el-input-focus-border-color: var(--gold-deep);
}
.login :deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 1px var(--ys-indigo) inset,
0 0 0 3px rgba(46, 75, 168, 0.13);
}
/* ============ 响应式 ============ */
@media (max-width: 640px) {
@media (max-width: 900px) {
.login {
padding: 24px 16px;
flex-direction: column;
}
.login-card {
padding: 26px 24px 28px;
.brand {
flex: 0 0 auto;
padding: 36px 26px 32px;
}
/* 窄屏上涟漪出画更多,避免挤压卡片 */
.login-ripple {
top: -260px;
right: -240px;
.brand-inner {
padding: 0;
}
.brand-flow {
opacity: 0.5;
}
.brand-name {
font-size: 30px;
letter-spacing: 0.16em;
}
.brand-rule {
margin: 16px 0 12px;
}
/* 窄屏上只留品牌识别,标语与环节行让位给表单 */
.brand-hero,
.brand-stages {
display: none;
}
.panel {
flex: 1;
padding: 38px 26px 46px;
}
}
/* 尊重系统的「减弱动效」偏好:保留终态,去掉过渡 */
@media (prefers-reduced-motion: reduce) {
.login-card {
.brand-flow__line {
stroke-dashoffset: 0;
animation: none;
}
.panel-inner {
opacity: 1;
animation: none;
}
}

View File

@ -1,374 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles'
import type { BusinessRoleItem, TemplateOption } from '@/api/businessRoles'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { STATUS_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
/**
* 岗位角色管理(需求 #28)。
*
* 与后端约定对齐:
* - 内置岗位(营销员/营销经理/信息员)禁删禁停 —— 前端禁用按钮,后端仍会拦截
* - 岗位编码创建后不可修改(它是参数维度值与模板绑定的锚点)
* - 公式模板为受控下拉(代码内注册表的有限集合);新增岗位 = 选模板 + 配参数,
* 无需改代码
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
const filters = reactive({
keyword: '',
status: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<BusinessRoleItem, businessRoleApi.BusinessRoleQueryParams>(
businessRoleApi.listBusinessRoles,
() => ({
keyword: filters.keyword,
status: filters.status,
}),
)
const templateOptions = ref<TemplateOption[]>([])
async function loadTemplateOptions(): Promise<void> {
try {
templateOptions.value = await businessRoleApi.listTemplateOptions()
} catch (error) {
handledError(error) // 拦截器已提示;下拉为空不阻断列表使用
}
}
/** 模板编码 → 名称(表格列展示用,下拉未加载完成时回退显示编码) */
function templateName(code: string): string {
return templateOptions.value.find((tpl) => tpl.code === code)?.name ?? code
}
function resetFilters(): void {
filters.keyword = ''
filters.status = undefined
search()
}
// ==================== 新增 / 编辑 ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
role_code: '',
role_name: '',
calc_template: '',
description: '',
sort_order: 0,
})
/** 编辑中的行(内置岗位的模板仍可改,但编码与删除/停用受保护) */
const editingRow = computed(() =>
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
const rules = computed<FormRules>(() => ({
role_code: isEdit.value
? []
: [
{ required: true, message: '请输入岗位编码', trigger: 'blur' },
{ pattern: /^[a-z][a-z0-9_]{1,31}$/, message: '编码须以小写字母开头,仅可含小写字母、数字与下划线', trigger: 'blur' },
],
role_name: [
{ required: true, message: '请输入岗位名称', trigger: 'blur' },
{ max: 64, message: '岗位名称最长 64 个字符', trigger: 'blur' },
],
calc_template: [{ required: true, message: '请选择计提公式模板', trigger: 'change' }],
description: [{ max: 255, message: '说明最长 255 个字符', trigger: 'blur' }],
}))
const selectedTemplate = computed(() =>
templateOptions.value.find((tpl) => tpl.code === form.calc_template) ?? null,
)
function openCreate(): void {
editingId.value = null
Object.assign(form, {
role_code: '',
role_name: '',
calc_template: '',
description: '',
sort_order: 0,
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: BusinessRoleItem): void {
editingId.value = row.id
Object.assign(form, {
role_code: row.role_code,
role_name: row.role_name,
calc_template: row.calc_template,
description: row.description ?? '',
sort_order: row.sort_order,
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editingId.value !== null) {
await businessRoleApi.updateBusinessRole(editingId.value, {
role_name: form.role_name,
calc_template: form.calc_template,
description: form.description,
sort_order: form.sort_order,
})
ElMessage.success('已保存')
} else {
await businessRoleApi.createBusinessRole({
role_code: form.role_code,
role_name: form.role_name,
calc_template: form.calc_template,
description: form.description || undefined,
sort_order: form.sort_order,
})
ElMessage.success('岗位已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 启停 / 删除 ====================
/** 内置岗位禁删禁停(按钮禁用 + title 说明原因) */
function isBuiltin(row: BusinessRoleItem): boolean {
return row.is_builtin === 1
}
function builtinHint(row: BusinessRoleItem): string {
return isBuiltin(row) ? '内置岗位不可删除或停用' : ''
}
async function handleToggleStatus(row: BusinessRoleItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '启用' : '禁用'
await confirmAndRun({
confirmText:
next === 0
? `确定禁用岗位「${row.role_name}」?禁用后新核算不再匹配该岗位。`
: `确定启用岗位「${row.role_name}」?`,
title: `${action}确认`,
confirmButtonText: action,
successText: `已${action}`,
run: () => businessRoleApi.setBusinessRoleStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: BusinessRoleItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除岗位「${row.role_name}」?删除为物理删除、不可恢复;该岗位编码相关的参数维度值不会随之清理。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '岗位已删除',
run: () => businessRoleApi.deleteBusinessRole(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadTemplateOptions()
})
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="岗位编码或名称"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增岗位</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="role_code" label="岗位编码" min-width="130">
<template #default="{ row }">
<span class="role-code">{{ row.role_code }}</span>
</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
<el-table-column label="公式模板" min-width="170">
<template #default="{ row }">{{ templateName(row.calc_template) }}</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="200">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="builtinHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span :title="builtinHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑岗位' : '新增岗位'"
width="540px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="岗位编码" prop="role_code">
<el-input
v-model="form.role_code"
:disabled="isEdit"
:placeholder="editingRow ? '' : '如 senior_sales,创建后不可修改'"
/>
</el-form-item>
<el-form-item label="岗位名称" prop="role_name">
<el-input v-model="form.role_name" placeholder="如 高级业务员" />
</el-form-item>
<el-form-item label="公式模板" prop="calc_template">
<el-select
v-model="form.calc_template"
placeholder="选择计提公式模板"
style="width: 100%"
>
<el-option
v-for="tpl in templateOptions"
:key="tpl.code"
:label="tpl.name"
:value="tpl.code"
/>
</el-select>
</el-form-item>
<el-form-item v-if="selectedTemplate" label-width="90px">
<div class="tpl-info">
<div>{{ selectedTemplate.description }}</div>
<div v-if="selectedTemplate.params.length" class="tpl-params">
涉及参数:{{ selectedTemplate.params.map((p) => p.name).join('、') }}(在「计提参数」中维护)
</div>
</div>
</el-form-item>
<el-form-item label="岗位说明" prop="description">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" :max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.role-code {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.tpl-info {
width: 100%;
padding: 8px 12px;
border-radius: 6px;
background: var(--ys-indigo-wash);
color: var(--ys-ink-2);
font-size: 12px;
line-height: 1.7;
}
.tpl-params {
color: var(--ys-ink-3);
}
</style>

View File

@ -1,998 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles'
import * as paramsApi from '@/api/params'
import type {
ParamBatch,
ParamBatchDetail,
ParamCatalogItem,
ParamDiff,
ParamHistory,
ParamPreviewResult,
} from '@/api/params'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { handledError } from '@/utils/error'
/**
* 计提参数配置(需求 #28)。
*
* 与后端约定对齐:
* - 目录按「分组 → 参数 → 维度值」展示当前生效值;修改先进「待提交」,
* 以批次提交(一次一批,逐项留痕旧值/新值)
* - 支持立即生效或指定日期生效;未生效批次可整批撤回
* - 图表/导出:统计用 ECharts(用户指定),导出为 xlsx 文件流
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 目录 ====================
const catalog = ref<ParamCatalogItem[]>([])
const catalogLoading = ref(false)
async function loadCatalog(): Promise<void> {
catalogLoading.value = true
try {
catalog.value = await paramsApi.getCatalog()
} catch (error) {
handledError(error)
} finally {
catalogLoading.value = false
}
}
/** 分组后的目录(保持后端 sort_order 顺序) */
const groupedCatalog = computed(() => {
const groups = new Map<string, ParamCatalogItem[]>()
for (const item of catalog.value) {
groups.set(item.param_group, [...(groups.get(item.param_group) ?? []), item])
}
return [...groups.entries()].map(([name, items]) => ({ name, items }))
})
/** 维度值 → 展示名(NONE 维度为空串) */
const DIMENSION_LABELS: Record<string, string> = {
'': '默认',
municipal_water: '市政水利',
rail_transit: '轨道交通',
}
function dimensionLabel(dimension: string): string {
return DIMENSION_LABELS[dimension] ?? dimension
}
/** 带单位的值展示 */
function formatValue(item: ParamCatalogItem, value: string): string {
return item.unit ? `${value} ${item.unit}` : value
}
// ==================== 修改(待提交) ====================
interface PendingChange {
item: ParamCatalogItem
dimension_value: string
old_value: string
new_value: string
}
const pending = reactive<Record<string, PendingChange>>({})
const pendingList = computed(() => Object.entries(pending).map(([key, change]) => ({ key, ...change })))
function pendingKey(paramCode: string, dimension: string): string {
return `${paramCode}|${dimension}`
}
function pendingOf(item: ParamCatalogItem, dimension: string): PendingChange | undefined {
return pending[pendingKey(item.param_code, dimension)]
}
const editVisible = ref(false)
const editTarget = ref<{ item: ParamCatalogItem; dimension: string } | null>(null)
const editValue = ref('')
/** 可选维度:该参数已有维度值(无则视为默认) */
const editDimensions = computed(() => {
if (!editTarget.value) return []
const dims = new Set<string>(editTarget.value.item.values.map((v) => v.dimension_value))
if (dims.size === 0) dims.add('')
return [...dims]
})
function currentValue(item: ParamCatalogItem, dimension: string): string | undefined {
return item.values.find((v) => v.dimension_value === dimension)?.value_text
}
function openEdit(item: ParamCatalogItem, dimension: string): void {
editTarget.value = { item, dimension }
editValue.value = currentValue(item, dimension) ?? item.default_value ?? ''
editVisible.value = true
}
function confirmEdit(): void {
if (!editTarget.value) return
const { item, dimension } = editTarget.value
const value = editValue.value.trim()
if (!value) {
ElMessage.warning('请输入新值')
return
}
const old = currentValue(item, dimension) ?? item.default_value ?? ''
if (value === old) {
ElMessage.info('新值与当前值相同,未加入待提交')
return
}
pending[pendingKey(item.param_code, dimension)] = {
item,
dimension_value: dimension,
old_value: old,
new_value: value,
}
editVisible.value = false
ElMessage.success('已加入待提交,提交前不会生效')
}
function removePending(key: string): void {
delete pending[key]
}
// ==================== 提交批次 ====================
const changeType = ref<1 | 2>(1)
const effectiveDate = ref<string>('')
const remark = ref('')
const submitting = ref(false)
const effectiveText = computed(() =>
changeType.value === 1 ? '立即生效' : `${effectiveDate.value} 起生效`,
)
async function submitAll(): Promise<void> {
if (changeType.value === 2 && !effectiveDate.value) {
ElMessage.warning('请选择生效日期')
return
}
try {
await ElMessageBox.confirm(
`共 ${pendingList.value.length} 项变更,${effectiveText.value}。提交后逐项留痕,未生效前可整批撤回。`,
'提交确认',
{ confirmButtonText: '提交', cancelButtonText: '再想想', type: 'warning' },
)
} catch {
return
}
submitting.value = true
try {
const batch = await paramsApi.submitChanges({
change_type: changeType.value,
effective_date: changeType.value === 2 ? effectiveDate.value : undefined,
remark: remark.value || undefined,
items: pendingList.value.map((p) => ({
param_code: p.item.param_code,
dimension_value: p.dimension_value,
value: p.new_value,
})),
})
ElMessage.success(`已提交,版本 ${batch.version_no}`)
Object.keys(pending).forEach((key) => delete pending[key])
remark.value = ''
await loadCatalog()
} catch (error) {
handledError(error)
} finally {
submitting.value = false
}
}
// ==================== 版本管理抽屉 ====================
const versionVisible = ref(false)
const batches = ref<ParamBatch[]>([])
const batchesTotal = ref(0)
const batchesPage = ref(1)
const batchesPageSize = ref(10)
const batchesLoading = ref(false)
const detailCache = reactive<Record<number, ParamBatchDetail>>({})
async function loadBatches(): Promise<void> {
batchesLoading.value = true
try {
const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: batchesPageSize.value })
batches.value = page.list
batchesTotal.value = page.total
} catch (error) {
handledError(error)
} finally {
batchesLoading.value = false
}
}
async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> {
if (detailCache[batch.id]) return detailCache[batch.id]
const detail = await paramsApi.getBatchDetail(batch.id)
detailCache[batch.id] = detail
return detail
}
function changeTypeText(changeType: number): string {
return changeType === 1 ? '立即生效' : '指定日期'
}
/** 本地日期(YYYY-MM-DD):toISOString 是 UTC,北京时间 0–8 点会差一天 */
function localToday(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function handleWithdraw(batch: ParamBatch): Promise<void> {
await confirmAndRun({
confirmText: `确定撤回批次「${batch.version_no}」(${batch.item_count} 项,${batch.effective_date} 起生效)?撤回后这些变更不再生效。`,
title: '撤回确认',
confirmButtonText: '撤回',
successText: '批次已撤回',
run: () => paramsApi.withdrawBatch(batch.id),
after: () => {
loadBatches()
void loadCatalog()
},
})
}
// ---------- 对比 ----------
const compareFrom = ref('')
const compareTo = ref('')
const comparing = ref(false)
const diffResult = ref<ParamDiff[]>([])
const diffSearched = ref(false)
async function doCompare(): Promise<void> {
if (!compareFrom.value || !compareTo.value) {
ElMessage.warning('请选择两个对比日期')
return
}
comparing.value = true
try {
diffResult.value = await paramsApi.compareParams(compareFrom.value, compareTo.value)
diffSearched.value = true
} catch (error) {
handledError(error)
} finally {
comparing.value = false
}
}
// ---------- 导出 ----------
const exporting = ref(false)
async function doExport(): Promise<void> {
exporting.value = true
try {
const blob = await paramsApi.exportChanges({
start_date: compareFrom.value || undefined,
end_date: compareTo.value || undefined,
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `param_changes_${localToday()}.xlsx`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('已导出')
} catch (error) {
handledError(error)
} finally {
exporting.value = false
}
}
function openVersionDrawer(): void {
versionVisible.value = true
batchesPage.value = 1
void loadBatches()
}
// ==================== 历史抽屉 ====================
const historyVisible = ref(false)
const historyItem = ref<ParamCatalogItem | null>(null)
const historyRows = ref<ParamHistory[]>([])
const historyTotal = ref(0)
const historyPage = ref(1)
const historyPageSize = ref(10)
const historyLoading = ref(false)
function openHistory(item: ParamCatalogItem): void {
historyItem.value = item
historyVisible.value = true
historyPage.value = 1
void loadHistory()
}
async function loadHistory(): Promise<void> {
if (!historyItem.value) return
historyLoading.value = true
try {
const page = await paramsApi.getParamHistory(historyItem.value.param_code, {
pageNum: historyPage.value,
pageSize: historyPageSize.value,
})
historyRows.value = page.list
historyTotal.value = page.total
} catch (error) {
handledError(error)
} finally {
historyLoading.value = false
}
}
// ==================== 计算预览抽屉 ====================
const previewVisible = ref(false)
const previewLoading = ref(false)
const previewResult = ref<ParamPreviewResult | null>(null)
const roleOptions = ref<businessRoleApi.BusinessRoleItem[]>([])
const previewForm = reactive({
business_role: 'salesperson',
customer_type: 'personal',
product_category: 'municipal_water',
ship_date: '',
receipt_date: '',
revenue: '',
profit: '',
received_amount: '',
barter: false,
probation: false,
})
const PRODUCT_OPTIONS = [
{ label: '市政水利', value: 'municipal_water' },
{ label: '轨道交通', value: 'rail_transit' },
]
const CUSTOMER_OPTIONS = [
{ label: '个人户', value: 'personal' },
{ label: '企业户', value: 'enterprise' },
]
async function loadRoleOptions(): Promise<void> {
try {
const page = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
roleOptions.value = page.list
} catch (error) {
handledError(error)
}
}
function openPreviewDrawer(): void {
previewVisible.value = true
if (roleOptions.value.length === 0) void loadRoleOptions()
}
async function runPreview(): Promise<void> {
if (!previewForm.ship_date || !previewForm.receipt_date) {
ElMessage.warning('请选择发货日与回款到账日')
return
}
previewLoading.value = true
try {
previewResult.value = await paramsApi.previewCalc({
business_role: previewForm.business_role || undefined,
customer_type: previewForm.customer_type || undefined,
product_category: previewForm.product_category || undefined,
ship_date: previewForm.ship_date,
receipt_date: previewForm.receipt_date,
revenue: previewForm.revenue,
profit: previewForm.profit,
received_amount: previewForm.received_amount,
barter: previewForm.barter,
probation: previewForm.probation,
})
} catch (error) {
handledError(error)
} finally {
previewLoading.value = false
}
}
// ==================== 初始化 ====================
onMounted(() => {
void loadCatalog()
})
</script>
<template>
<div>
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。
</span>
<span>
<el-button @click="openPreviewDrawer">计算预览</el-button>
<el-button @click="openVersionDrawer">版本管理</el-button>
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
</span>
</div>
<el-collapse v-loading="catalogLoading">
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
<template #title>
<span class="group-title">{{ group.name }}</span>
</template>
<table class="param-table">
<thead>
<tr>
<th style="width: 190px">参数</th>
<th>当前生效值</th>
<th style="width: 110px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in group.items" :key="item.param_code">
<td>
<div class="param-name">{{ item.param_name }}</div>
<div class="param-desc">{{ item.description || item.param_code }}</div>
</td>
<td>
<template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
size="small"
type="warning"
disable-transitions
>
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag>
<el-button
link
type="primary"
size="small"
@click="openEdit(item, v.dimension_value)"
>
改
</el-button>
</div>
</template>
<span v-else class="no-value">
未设置(默认 {{ item.default_value ?? '—' }})
</span>
</td>
<td>
<el-button
v-if="!item.values.length"
link
type="primary"
size="small"
@click="openEdit(item, '')"
>
设置
</el-button>
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
</td>
</tr>
</tbody>
</table>
</el-collapse-item>
</el-collapse>
</el-card>
<!-- 待提交批次 -->
<el-card v-if="pendingList.length" shadow="never" class="pending-card">
<template #header>
<div class="pending-head">
<b>待提交变更({{ pendingList.length }} 项)</b>
<span class="pending-hint">提交前不会生效;提交后未生效前可整批撤回</span>
</div>
</template>
<el-table :data="pendingList" size="small">
<el-table-column label="参数" min-width="160">
<template #default="{ row }">{{ row.item.param_name }}</template>
</el-table-column>
<el-table-column label="维度" width="110">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="110">
<template #default="{ row }">{{ row.old_value || '—' }}</template>
</el-table-column>
<el-table-column label="新值" min-width="110">
<template #default="{ row }"><b class="num">{{ row.new_value }}</b></template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removePending(row.key)">移除</el-button>
</template>
</el-table-column>
</el-table>
<div class="submit-row">
<el-radio-group v-model="changeType">
<el-radio-button :value="1">立即生效</el-radio-button>
<el-radio-button :value="2">指定日期生效</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="changeType === 2"
v-model="effectiveDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 150px"
/>
<el-input
v-model="remark"
placeholder="变更说明(如:四季度系数调整)"
style="width: 240px"
maxlength="255"
/>
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
</div>
</el-card>
<!-- 修改参数 -->
<el-dialog v-model="editVisible" title="修改参数" width="440px" destroy-on-close>
<template v-if="editTarget">
<div class="edit-name">{{ editTarget.item.param_name }}</div>
<div class="edit-desc">{{ editTarget.item.description || editTarget.item.param_code }}</div>
<el-form label-width="80px" style="margin-top: 16px" @submit.prevent>
<el-form-item v-if="editDimensions.length > 1 || editTarget.item.dimension_type !== 'NONE'" label="维度">
<el-select v-model="editTarget.dimension" style="width: 100%">
<el-option
v-for="dim in editDimensions"
:key="dim"
:label="dimensionLabel(dim)"
:value="dim"
/>
</el-select>
</el-form-item>
<el-form-item label="当前值">
<span class="num">{{ currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—' }}</span>
<span v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
</el-form-item>
<el-form-item label="新值">
<el-select
v-if="editTarget.item.value_type === 'ENUM'"
v-model="editValue"
style="width: 100%"
>
<el-option v-for="opt in editTarget.item.options" :key="opt" :label="opt" :value="opt" />
</el-select>
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
</el-form-item>
</el-form>
<div class="edit-tip">
取值范围:{{ editTarget.item.value_min ?? '−' }} ~ {{ editTarget.item.value_max ?? '不限' }};
精确分数(如 4/360)原样存储、不做小数截断。
</div>
</template>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="confirmEdit">加入待提交</el-button>
</template>
</el-dialog>
<!-- 版本管理抽屉 -->
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
<div class="drawer-block">
<div class="drawer-row">
<el-date-picker
v-model="compareFrom"
type="date"
value-format="YYYY-MM-DD"
placeholder="对比起始日期"
style="width: 150px"
/>
<el-date-picker
v-model="compareTo"
type="date"
value-format="YYYY-MM-DD"
placeholder="对比截止日期"
style="width: 150px"
/>
<el-button :loading="comparing" @click="doCompare">对比</el-button>
<el-button :loading="exporting" @click="doExport">导出记录</el-button>
</div>
<template v-if="diffSearched">
<div class="drawer-title">快照对比({{ compareFrom }} → {{ compareTo }})</div>
<el-table :data="diffResult" size="small" max-height="260">
<el-table-column prop="param_name" label="参数" min-width="130" />
<el-table-column label="维度" width="90">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="90">
<template #default="{ row }">{{ row.old_value ?? '—' }}</template>
</el-table-column>
<el-table-column label="新值" min-width="90">
<template #default="{ row }">
<span :class="{ changed: row.changed }">{{ row.new_value ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="变化" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.changed" size="small" type="warning" disable-transitions>变化</el-tag>
</template>
</el-table-column>
</el-table>
</template>
</div>
<div class="drawer-block">
<div class="drawer-title">变更记录</div>
<el-table v-loading="batchesLoading" :data="batches" size="small">
<el-table-column type="expand">
<template #default="{ row }">
<div class="batch-items">
<template v-if="detailCache[row.id]">
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
<span class="param-name">{{ item.param_name }}</span>
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
</div>
</template>
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
加载明细…
</el-button>
</div>
</template>
</el-table-column>
<el-table-column prop="version_no" label="版本号" width="120" />
<el-table-column prop="effective_date" label="生效日期" width="110" />
<el-table-column label="方式" width="90">
<template #default="{ row }">{{ changeTypeText(row.change_type) }}</template>
</el-table-column>
<el-table-column prop="item_count" label="项数" width="60" align="center" />
<el-table-column prop="operator_name" label="操作人" width="90" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button
v-if="row.effective_date > localToday()"
link
type="danger"
size="small"
@click="handleWithdraw(row)"
>
撤回
</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="batchesTotal"
:page-num="batchesPage"
:page-size="batchesPageSize"
@page-change="(p: number) => { batchesPage = p; void loadBatches() }"
@size-change="(s: number) => { batchesPageSize = s; batchesPage = 1; void loadBatches() }"
/>
</div>
</el-drawer>
<!-- 历史抽屉 -->
<el-drawer v-model="historyVisible" :title="`参数历史 · ${historyItem?.param_name ?? ''}`" size="560px">
<el-table v-loading="historyLoading" :data="historyRows" size="small">
<el-table-column prop="effective_date" label="生效日期" width="105" />
<el-table-column label="维度" width="90">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值 → 新值" min-width="130">
<template #default="{ row }">
<span class="num">{{ row.old_value_text ?? '—' }} → {{ row.value_text }}</span>
</template>
</el-table-column>
<el-table-column prop="version_no" label="版本" width="115" />
<el-table-column prop="operator_name" label="操作人" width="80" />
</el-table>
<AppPagination
:total="historyTotal"
:page-num="historyPage"
:page-size="historyPageSize"
@page-change="(p: number) => { historyPage = p; void loadHistory() }"
@size-change="(s: number) => { historyPageSize = s; historyPage = 1; void loadHistory() }"
/>
</el-drawer>
<!-- 计算预览抽屉 -->
<el-drawer v-model="previewVisible" title="计算预览" size="480px">
<el-form label-width="90px" @submit.prevent>
<el-form-item label="客户类型">
<el-select v-model="previewForm.customer_type" style="width: 100%">
<el-option v-for="o in CUSTOMER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="岗位">
<el-select v-model="previewForm.business_role" clearable style="width: 100%">
<el-option
v-for="role in roleOptions"
:key="role.role_code"
:label="role.role_name"
:value="role.role_code"
/>
</el-select>
</el-form-item>
<el-form-item label="产品类别">
<el-select v-model="previewForm.product_category" style="width: 100%">
<el-option v-for="o in PRODUCT_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="发货日">
<el-date-picker
v-model="previewForm.ship_date"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="回款到账日">
<el-date-picker
v-model="previewForm.receipt_date"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="销售额">
<el-input v-model="previewForm.revenue" placeholder="不含运费" />
</el-form-item>
<el-form-item label="毛利润额">
<el-input v-model="previewForm.profit" placeholder="可为负" />
</el-form-item>
<el-form-item label="本笔回款">
<el-input v-model="previewForm.received_amount" />
</el-form-item>
<el-form-item label="以物抵顶">
<el-switch v-model="previewForm.barter" />
</el-form-item>
<el-form-item label="试用期">
<el-switch v-model="previewForm.probation" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="previewLoading" @click="runPreview">试 算</el-button>
</el-form-item>
</el-form>
<template v-if="previewResult">
<div class="preview-result">
<div class="pr-row big">
<span>单笔绩效</span><b class="num">{{ previewResult.performance_amount }} 元</b>
</div>
<div class="pr-row"><span>回款天数</span><b class="num">{{ previewResult.payment_days }} 天</b></div>
<div class="pr-row"><span>计提比例</span><b class="num">{{ previewResult.rate }}</b></div>
<div class="pr-row"><span>计提说明</span><b>{{ previewResult.rate_text }}</b></div>
<div class="pr-row"><span>折算毛利润额</span><b class="num">{{ previewResult.converted_profit }} 元</b></div>
<div class="pr-row"><span>毛利润率</span><b class="num">{{ previewResult.profit_rate }}</b></div>
<div class="pr-row"><span>起付判定</span><b>{{ previewResult.start_payment_ok ? '已达标' : '未达标(累计中)' }}</b></div>
<div class="pr-row"><span>支付权重</span><b class="num">R={{ previewResult.payment_weight_r }},权重 {{ previewResult.payment_weight }}</b></div>
<div class="pr-row"><span>当月可支付</span><b class="num">{{ previewResult.payable_amount }} 元</b></div>
<div v-if="previewResult.probation_note" class="pr-note">{{ previewResult.probation_note }}</div>
</div>
<div class="pr-used">
<div class="drawer-title">所用参数(按回款到账日取值)</div>
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
{{ u.param_code }} = {{ u.value_text }}
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
</div>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.page-desc {
font-size: 13px;
color: var(--ys-ink-2);
}
.group-title {
font-weight: 600;
color: var(--ys-ink);
}
.param-table {
width: 100%;
border-collapse: collapse;
}
.param-table th {
text-align: left;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--ys-ink-2);
padding: 6px 8px;
border-bottom: 1px solid var(--ys-line);
}
.param-table td {
padding: 10px 8px;
border-bottom: 1px solid var(--ys-line);
font-size: 13px;
vertical-align: top;
}
.param-name {
font-weight: 500;
}
.param-desc {
font-size: 12px;
color: var(--ys-ink-3);
margin-top: 2px;
}
.value-row {
display: flex;
align-items: baseline;
gap: 8px;
line-height: 1.9;
}
.dim-label {
font-size: 12px;
color: var(--ys-ink-3);
min-width: 56px;
}
.num {
font-variant-numeric: tabular-nums;
}
.eff {
font-size: 12px;
color: var(--ys-ink-3);
}
.no-value {
color: var(--ys-ink-3);
font-size: 12px;
}
.pending-card {
margin-top: 14px;
}
.pending-head {
display: flex;
align-items: baseline;
gap: 12px;
}
.pending-hint {
font-size: 12px;
color: var(--ys-ink-3);
}
.submit-row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 14px;
}
.edit-name {
font-size: 15px;
font-weight: 600;
}
.edit-desc {
font-size: 12px;
color: var(--ys-ink-3);
margin-top: 4px;
}
.edit-tip {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
}
.drawer-block {
margin-bottom: 24px;
}
.drawer-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 12px;
}
.drawer-title {
font-size: 13px;
font-weight: 600;
color: var(--ys-ink-2);
margin: 10px 0 8px;
}
.batch-items {
padding: 6px 12px;
background: var(--ys-mist);
border-radius: 6px;
}
.batch-item {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 12.5px;
line-height: 2;
}
.batch-item .param-name {
min-width: 130px;
color: var(--ys-ink);
}
.changed {
color: var(--ys-indigo);
font-weight: 600;
}
.preview-result {
margin-top: 18px;
border: 1px solid var(--ys-line);
border-radius: 10px;
padding: 16px 18px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
}
.pr-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
font-size: 13px;
color: var(--ys-ink-2);
}
.pr-row.big {
border-bottom: 1px solid var(--ys-line);
margin-bottom: 6px;
padding-bottom: 10px;
}
.pr-row.big b {
font-size: 20px;
color: var(--ys-indigo);
}
.pr-row b {
color: var(--ys-ink);
font-weight: 600;
}
.pr-note {
margin-top: 10px;
font-size: 12px;
color: var(--ys-warn);
background: #fbf4e4;
border-radius: 6px;
padding: 8px 12px;
}
.pr-used {
margin-top: 16px;
}
.pr-used-row {
font-size: 12px;
color: var(--ys-ink-2);
line-height: 2;
font-variant-numeric: tabular-nums;
}
</style>