Compare commits
10 Commits
c7212cd93f
...
fbf044f240
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf044f240 | ||
|
|
6219698a39 | ||
|
|
d7fbec1177 | ||
|
|
4c442b8cf2 | ||
|
|
fd801455f0 | ||
|
|
8ad3650dd3 | ||
|
|
7a0f3f4b98 | ||
|
|
1b3509b20f | ||
|
|
5ed5665a54 | ||
|
|
bc019cadf5 |
@ -1,12 +1,13 @@
|
||||
# 后端
|
||||
|
||||
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与系统管理(用户/角色/操作日志/日志查询),业务模块待需求澄清后开发。
|
||||
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。已实现登录鉴权、系统管理(用户/角色/操作日志/日志查询)与 M1 参数体系(岗位角色管理、计提参数版本化/留痕/对比/预览/导出,2026-10-13);业务模块(基础/业务数据、核算、报表)按路线图推进。
|
||||
|
||||
## 职责
|
||||
|
||||
- 提供 REST 接口(统一前缀 `/api`)
|
||||
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
|
||||
- 系统管理(需求 2.5.1):用户管理、角色管理、操作日志(自动审计)、操作与登录日志查询
|
||||
- 系统管理(需求 2.5.1):用户管理、角色管理、岗位角色(#28 可扩展+公式模板绑定)、操作日志(自动审计)、操作与登录日志查询
|
||||
- 计提参数体系(需求 #28):16 项参数目录、按生效日期版本化(回款到账日取值)、变更批次留痕/对比/导出、计算预览
|
||||
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
|
||||
|
||||
## 技术栈与版本
|
||||
|
||||
@ -0,0 +1,810 @@
|
||||
"""新增岗位角色与计提参数四表
|
||||
|
||||
Revision ID: bfbda9c3191d
|
||||
Revises: a45a8087831b
|
||||
Create Date: 2026-10-12 16:01:48.760620
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "bfbda9c3191d"
|
||||
down_revision: str | Sequence[str] | None = "a45a8087831b"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移。"""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"business_roles",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"role_code",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改",
|
||||
),
|
||||
sa.Column("role_name", sa.String(length=64), nullable=False, comment="岗位名称"),
|
||||
sa.Column(
|
||||
"calc_template",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="计提公式模板编码(以代码内模板注册表为准)",
|
||||
),
|
||||
sa.Column("description", sa.String(length=255), nullable=True, comment="岗位说明"),
|
||||
sa.Column(
|
||||
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="状态:1启用 0禁用",
|
||||
),
|
||||
sa.Column(
|
||||
"is_builtin",
|
||||
mysql.TINYINT(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="是否内置岗位:1内置(禁删禁停) 0自定义",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.Column(
|
||||
"update_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="更新时间",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_roles")),
|
||||
sa.UniqueConstraint("role_code", name=op.f("uk_business_roles_role_code")),
|
||||
comment="业务岗位角色表(与登录角色roles区分,决定计提公式与参数)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_table(
|
||||
"param_definitions",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"param_code",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="参数编码,如 salesperson.rate.slope",
|
||||
),
|
||||
sa.Column("param_name", sa.String(length=64), nullable=False, comment="参数名称"),
|
||||
sa.Column(
|
||||
"param_group",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径",
|
||||
),
|
||||
sa.Column(
|
||||
"value_type",
|
||||
sa.String(length=16),
|
||||
nullable=False,
|
||||
comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT",
|
||||
),
|
||||
sa.Column(
|
||||
"unit", sa.String(length=16), nullable=True, comment="展示单位:‰/%/元/天,无单位为空"
|
||||
),
|
||||
sa.Column(
|
||||
"dimension_type",
|
||||
sa.String(length=32),
|
||||
server_default="NONE",
|
||||
nullable=False,
|
||||
comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE",
|
||||
),
|
||||
sa.Column(
|
||||
"options_text",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="枚举选项(竖线分隔),仅 ENUM 使用",
|
||||
),
|
||||
sa.Column(
|
||||
"default_value",
|
||||
sa.String(length=64),
|
||||
nullable=True,
|
||||
comment="系统默认值(Fraction 可解析字符串)",
|
||||
),
|
||||
sa.Column(
|
||||
"value_min", sa.String(length=64), nullable=True, comment="取值下限(含),校验用"
|
||||
),
|
||||
sa.Column(
|
||||
"value_max", sa.String(length=64), nullable=True, comment="取值上限(含),校验用"
|
||||
),
|
||||
sa.Column(
|
||||
"description", sa.String(length=255), nullable=True, comment="口径说明(配置页展示)"
|
||||
),
|
||||
sa.Column(
|
||||
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="状态:1启用 0停用",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.Column(
|
||||
"update_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="更新时间",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_definitions")),
|
||||
sa.UniqueConstraint("param_code", name=op.f("uk_param_definitions_param_code")),
|
||||
comment="计提参数定义表",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_table(
|
||||
"param_change_batches",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"version_no", sa.String(length=32), nullable=False, comment="版本号,如 P20261012-01"
|
||||
),
|
||||
sa.Column("effective_date", sa.Date(), nullable=False, comment="生效日期(业务日期口径)"),
|
||||
sa.Column(
|
||||
"change_type",
|
||||
mysql.TINYINT(),
|
||||
nullable=False,
|
||||
comment="生效方式:1立即生效 2指定日期生效",
|
||||
),
|
||||
sa.Column("remark", sa.String(length=255), nullable=True, comment="变更说明"),
|
||||
sa.Column(
|
||||
"operator_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=True,
|
||||
comment="操作人ID(账号删除后置空)",
|
||||
),
|
||||
sa.Column(
|
||||
"operator_name",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="操作人用户名(冗余固化)",
|
||||
),
|
||||
sa.Column(
|
||||
"item_count", sa.Integer(), server_default="0", nullable=False, comment="变更参数项数"
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["operator_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_param_change_batches_operator_id"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_change_batches")),
|
||||
sa.UniqueConstraint("version_no", name=op.f("uk_param_change_batches_version_no")),
|
||||
comment="参数变更批次表",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_table(
|
||||
"param_values",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"param_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=False,
|
||||
comment="参数定义ID(param_definitions.id)",
|
||||
),
|
||||
sa.Column(
|
||||
"dimension_value",
|
||||
sa.String(length=32),
|
||||
server_default=sa.text("''"),
|
||||
nullable=False,
|
||||
comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值",
|
||||
),
|
||||
sa.Column(
|
||||
"effective_date", sa.Date(), nullable=False, comment="生效日期(按业务日期时点化取值)"
|
||||
),
|
||||
sa.Column(
|
||||
"value_text",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="参数值(Fraction 可解析字符串/枚举码/开关)",
|
||||
),
|
||||
sa.Column(
|
||||
"old_value_text", sa.String(length=64), nullable=True, comment="变更前值(留痕)"
|
||||
),
|
||||
sa.Column(
|
||||
"batch_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=False,
|
||||
comment="变更批次ID(param_change_batches.id)",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["batch_id"],
|
||||
["param_change_batches.id"],
|
||||
name=op.f("fk_param_values_batch_id"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["param_id"],
|
||||
["param_definitions.id"],
|
||||
name=op.f("fk_param_values_param_id"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_values")),
|
||||
comment="参数版本条目表(append-only,按生效日期时点化取值)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_index("idx_param_values_batch_id", "param_values", ["batch_id"], unique=False)
|
||||
op.create_index(
|
||||
"idx_param_values_effective_date", "param_values", ["effective_date"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"idx_param_values_param_id_dimension_value_effective_date",
|
||||
"param_values",
|
||||
["param_id", "dimension_value", "effective_date"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
# ---- 种子数据(生产与开发同源;口径依据 2026-10-12 业务裁决对照表)----
|
||||
# 显式指定 id:四张表均为本迁移新建、应用时必为空表,id 稳定可控;
|
||||
# param_values 按 param_id 引用定义,无法依赖自增(bulk_insert 取不回自增值)。
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"business_roles",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("role_code", sa.String),
|
||||
sa.column("role_name", sa.String),
|
||||
sa.column("calc_template", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
sa.column("sort_order", sa.Integer),
|
||||
sa.column("is_builtin", sa.Integer),
|
||||
),
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"role_code": "salesperson",
|
||||
"role_name": "营销员",
|
||||
"calc_template": "TPL_SALESPERSON_DECAY",
|
||||
"description": "业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)",
|
||||
"sort_order": 1,
|
||||
"is_builtin": 1,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"role_code": "sales_manager",
|
||||
"role_name": "营销经理",
|
||||
"calc_template": "TPL_MANAGER_SHARE",
|
||||
"description": "经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
|
||||
"sort_order": 2,
|
||||
"is_builtin": 1,
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"role_code": "info_officer",
|
||||
"role_name": "信息员",
|
||||
"calc_template": "TPL_INFO_SALES_FIXED",
|
||||
"description": "信息员绩效:本人销售额×1‰",
|
||||
"sort_order": 3,
|
||||
"is_builtin": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"param_change_batches",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("version_no", sa.String),
|
||||
sa.column("effective_date", sa.Date),
|
||||
sa.column("change_type", sa.Integer),
|
||||
sa.column("remark", sa.String),
|
||||
sa.column("operator_name", sa.String),
|
||||
sa.column("item_count", sa.Integer),
|
||||
),
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"version_no": "P20260101-00",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"change_type": 1,
|
||||
"remark": "初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)",
|
||||
"operator_name": "system",
|
||||
"item_count": 19,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"param_definitions",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("param_code", sa.String),
|
||||
sa.column("param_name", sa.String),
|
||||
sa.column("param_group", sa.String),
|
||||
sa.column("value_type", sa.String),
|
||||
sa.column("unit", sa.String),
|
||||
sa.column("dimension_type", sa.String),
|
||||
sa.column("options_text", sa.String),
|
||||
sa.column("default_value", sa.String),
|
||||
sa.column("value_min", sa.String),
|
||||
sa.column("value_max", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
sa.column("sort_order", sa.Integer),
|
||||
),
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"param_code": "salesperson.rate.start",
|
||||
"param_name": "业务员起始计提比例",
|
||||
"param_group": "业务员个人户",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "‰",
|
||||
"dimension_type": "BUSINESS_ROLE",
|
||||
"options_text": None,
|
||||
"default_value": "10",
|
||||
"value_min": "0",
|
||||
"value_max": None,
|
||||
"description": "线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
|
||||
"sort_order": 1,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"param_code": "salesperson.rate.slope",
|
||||
"param_name": "业务员日递减斜率",
|
||||
"param_group": "业务员个人户",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "‰/天",
|
||||
"dimension_type": "BUSINESS_ROLE",
|
||||
"options_text": None,
|
||||
"default_value": "4/360",
|
||||
"value_min": "0",
|
||||
"value_max": None,
|
||||
"description": "精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
|
||||
"sort_order": 2,
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"param_code": "salesperson.rate.floor",
|
||||
"param_name": "业务员计提下限",
|
||||
"param_group": "业务员个人户",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "‰",
|
||||
"dimension_type": "BUSINESS_ROLE",
|
||||
"options_text": None,
|
||||
"default_value": "4",
|
||||
"value_min": "0",
|
||||
"value_max": None,
|
||||
"description": "回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
|
||||
"sort_order": 3,
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"param_code": "salesperson.days.boundary",
|
||||
"param_name": "线性递减分界天数",
|
||||
"param_group": "业务员个人户",
|
||||
"value_type": "INT",
|
||||
"unit": "天",
|
||||
"dimension_type": "BUSINESS_ROLE",
|
||||
"options_text": None,
|
||||
"default_value": "360",
|
||||
"value_min": "0",
|
||||
"value_max": None,
|
||||
"description": "≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
|
||||
"sort_order": 4,
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"param_code": "manager.share.team",
|
||||
"param_name": "经理本团队分成比例",
|
||||
"param_group": "经理分成",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "%",
|
||||
"dimension_type": "BUSINESS_ROLE",
|
||||
"options_text": None,
|
||||
"default_value": "12.5",
|
||||
"value_min": "0",
|
||||
"value_max": "100",
|
||||
"description": "∑本人带领业务员绩效 × 此比例(限本团队)",
|
||||
"sort_order": 5,
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"param_code": "manager.share.company",
|
||||
"param_name": "经理全公司分成比例",
|
||||
"param_group": "经理分成",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "%",
|
||||
"dimension_type": "BUSINESS_ROLE",
|
||||
"options_text": None,
|
||||
"default_value": "5",
|
||||
"value_min": "0",
|
||||
"value_max": "100",
|
||||
"description": "∑全部业务员绩效 × 此比例(全公司)",
|
||||
"sort_order": 6,
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"param_code": "enterprise.rate",
|
||||
"param_name": "企业户计提比例",
|
||||
"param_group": "企业户",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "‰",
|
||||
"dimension_type": "PRODUCT_CATEGORY",
|
||||
"options_text": None,
|
||||
"default_value": "3",
|
||||
"value_min": "0",
|
||||
"value_max": None,
|
||||
"description": "所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
|
||||
"sort_order": 7,
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"param_code": "info.rate",
|
||||
"param_name": "信息员计提比例",
|
||||
"param_group": "信息员",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "‰",
|
||||
"dimension_type": "BUSINESS_ROLE",
|
||||
"options_text": None,
|
||||
"default_value": "1",
|
||||
"value_min": "0",
|
||||
"value_max": None,
|
||||
"description": "信息员绩效 = 本人销售额 × 此比例",
|
||||
"sort_order": 8,
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"param_code": "prepay.rate",
|
||||
"param_name": "预付款计提比例",
|
||||
"param_group": "支付环节",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "‰",
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "10",
|
||||
"value_min": "0",
|
||||
"value_max": None,
|
||||
"description": "预付款统一按此比例计提",
|
||||
"sort_order": 9,
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"param_code": "barter.discount",
|
||||
"param_name": "以物抵顶折算比例",
|
||||
"param_group": "支付环节",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "%",
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "80",
|
||||
"value_min": "0",
|
||||
"value_max": "100",
|
||||
"description": "以物抵顶回款绩效按应发绩效的此比例折算",
|
||||
"sort_order": 10,
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"param_code": "payment.start.ratio",
|
||||
"param_name": "支付起付比例",
|
||||
"param_group": "支付环节",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "%",
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "60",
|
||||
"value_min": "0",
|
||||
"value_max": "100",
|
||||
"description": "单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
|
||||
"sort_order": 11,
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"param_code": "payment.weight.mode",
|
||||
"param_name": "支付权重应用方式",
|
||||
"param_group": "支付环节",
|
||||
"value_type": "ENUM",
|
||||
"unit": None,
|
||||
"dimension_type": "NONE",
|
||||
"options_text": "FULL|BALANCE",
|
||||
"default_value": "FULL",
|
||||
"value_min": None,
|
||||
"value_max": None,
|
||||
"description": "FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
|
||||
"sort_order": 12,
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"param_code": "product.threshold",
|
||||
"param_name": "折算毛利润额阈值",
|
||||
"param_group": "产品阈值",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "%",
|
||||
"dimension_type": "PRODUCT_CATEGORY",
|
||||
"options_text": None,
|
||||
"default_value": "30",
|
||||
"value_min": "0",
|
||||
"value_max": "100",
|
||||
"description": "折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
|
||||
"sort_order": 13,
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"param_code": "salary.basic.mode",
|
||||
"param_name": "月工资基本工资口径",
|
||||
"param_group": "月工资口径",
|
||||
"value_type": "ENUM",
|
||||
"unit": None,
|
||||
"dimension_type": "BUSINESS_ROLE",
|
||||
"options_text": "INCLUDE|DEDUCT|EXCLUDE",
|
||||
"default_value": "INCLUDE",
|
||||
"value_min": None,
|
||||
"value_max": None,
|
||||
"description": "INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
|
||||
"sort_order": 14,
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"param_code": "probation.annual.threshold",
|
||||
"param_name": "试用期年累计结算阈值",
|
||||
"param_group": "月工资口径",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "元",
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "18000",
|
||||
"value_min": "0",
|
||||
"value_max": None,
|
||||
"description": "试用期年累计绩效超过此值部分年底一次性结算",
|
||||
"sort_order": 15,
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"param_code": "probation.salary.basic",
|
||||
"param_name": "试用期基本工资标准",
|
||||
"param_group": "月工资口径",
|
||||
"value_type": "DECIMAL",
|
||||
"unit": "元",
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "3000",
|
||||
"value_min": "0",
|
||||
"value_max": None,
|
||||
"description": "试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
|
||||
"sort_order": 16,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"param_values",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("param_id", sa.BigInteger),
|
||||
sa.column("dimension_value", sa.String),
|
||||
sa.column("effective_date", sa.Date),
|
||||
sa.column("value_text", sa.String),
|
||||
sa.column("batch_id", sa.BigInteger),
|
||||
),
|
||||
[
|
||||
# 业务员个人户(2026-02-17 折算公式执行日起)
|
||||
{
|
||||
"id": 1,
|
||||
"param_id": 1,
|
||||
"dimension_value": "salesperson",
|
||||
"effective_date": date(2026, 2, 17),
|
||||
"value_text": "10",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"param_id": 2,
|
||||
"dimension_value": "salesperson",
|
||||
"effective_date": date(2026, 2, 17),
|
||||
"value_text": "4/360",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"param_id": 3,
|
||||
"dimension_value": "salesperson",
|
||||
"effective_date": date(2026, 2, 17),
|
||||
"value_text": "4",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"param_id": 4,
|
||||
"dimension_value": "salesperson",
|
||||
"effective_date": date(2026, 2, 17),
|
||||
"value_text": "360",
|
||||
"batch_id": 1,
|
||||
},
|
||||
# 经理与信息员(2026-08-13 执行日起)
|
||||
{
|
||||
"id": 5,
|
||||
"param_id": 5,
|
||||
"dimension_value": "sales_manager",
|
||||
"effective_date": date(2026, 8, 13),
|
||||
"value_text": "12.5",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"param_id": 6,
|
||||
"dimension_value": "sales_manager",
|
||||
"effective_date": date(2026, 8, 13),
|
||||
"value_text": "5",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"param_id": 7,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 2, 17),
|
||||
"value_text": "2",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"param_id": 7,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 8, 13),
|
||||
"value_text": "3",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"param_id": 8,
|
||||
"dimension_value": "info_officer",
|
||||
"effective_date": date(2026, 8, 13),
|
||||
"value_text": "1",
|
||||
"batch_id": 1,
|
||||
},
|
||||
# 支付环节与通用口径(需规默认值,无历史冲突)
|
||||
{
|
||||
"id": 10,
|
||||
"param_id": 9,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "10",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"param_id": 10,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "80",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"param_id": 11,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "60",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"param_id": 12,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "FULL",
|
||||
"batch_id": 1,
|
||||
},
|
||||
# 产品阈值(按产品类别维度)
|
||||
{
|
||||
"id": 14,
|
||||
"param_id": 13,
|
||||
"dimension_value": "municipal_water",
|
||||
"effective_date": date(2026, 2, 17),
|
||||
"value_text": "30",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"param_id": 13,
|
||||
"dimension_value": "rail_transit",
|
||||
"effective_date": date(2026, 2, 17),
|
||||
"value_text": "20",
|
||||
"batch_id": 1,
|
||||
},
|
||||
# 月工资口径(按岗位维度)
|
||||
{
|
||||
"id": 16,
|
||||
"param_id": 14,
|
||||
"dimension_value": "salesperson",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "INCLUDE",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"param_id": 14,
|
||||
"dimension_value": "sales_manager",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "DEDUCT",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"param_id": 15,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "18000",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"param_id": 16,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "3000",
|
||||
"batch_id": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移。
|
||||
|
||||
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
|
||||
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
|
||||
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
|
||||
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
|
||||
"""
|
||||
# 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
|
||||
# 原 autogenerate 输出的逐个 drop_index 会失败:组合索引最左列 param_id 与
|
||||
# idx_param_values_batch_id 分别是两个外键的唯一支撑索引(MySQL errno 1553)。
|
||||
# 删除顺序:先删引用方 param_values,再删被引用方。
|
||||
op.drop_table("param_values")
|
||||
op.drop_table("param_change_batches")
|
||||
op.drop_table("param_definitions")
|
||||
op.drop_table("business_roles")
|
||||
@ -8,7 +8,9 @@ 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
|
||||
|
||||
@ -25,6 +27,8 @@ 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)
|
||||
|
||||
122
backend/app/api/v1/system/business_roles.py
Normal file
122
backend/app/api/v1/system/business_roles.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""业务岗位角色管理接口(需求 #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="岗位已删除")
|
||||
147
backend/app/api/v1/system/params.py
Normal file
147
backend/app/api/v1/system/params.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""计提参数管理接口(需求 #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))
|
||||
@ -37,10 +37,22 @@ 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 = "修改"
|
||||
|
||||
@ -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
|
||||
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"),
|
||||
# 手机号:138****8888(兼容带空格/横线分隔的写法,如 138 0013 8000)
|
||||
(re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"),
|
||||
# 18 位身份证:保留前 6 位与后 4 位
|
||||
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
|
||||
)
|
||||
|
||||
@ -10,15 +10,21 @@
|
||||
"""
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.business_role import BusinessRole
|
||||
from app.models.login_log import LoginLog
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
||||
from app.models.role import Permission, Role, RolePermission
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"BusinessRole",
|
||||
"LoginLog",
|
||||
"OperationLog",
|
||||
"ParamChangeBatch",
|
||||
"ParamDefinition",
|
||||
"ParamValue",
|
||||
"Permission",
|
||||
"Role",
|
||||
"RolePermission",
|
||||
|
||||
46
backend/app/models/business_role.py
Normal file
46
backend/app/models/business_role.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""业务岗位角色模型。
|
||||
|
||||
与系统登录角色(`roles`,权限维度)严格区分:本表是**业务岗位**维度,
|
||||
决定人员适用的计提公式模板与参数。需求 #28 要求岗位角色可增删、
|
||||
新增岗位时在系统设置中绑定公式与参数(无需改代码),故模板编码
|
||||
存于本表、公式实现留在代码内注册表(一期不引入规则引擎)。
|
||||
"""
|
||||
|
||||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class BusinessRole(Base, TimestampMixin):
|
||||
"""业务岗位角色表。内置三个岗位(营销员/营销经理/信息员)随迁移初始化。"""
|
||||
|
||||
__tablename__ = "business_roles"
|
||||
__table_args__ = mysql_table_args("业务岗位角色表(与登录角色roles区分,决定计提公式与参数)")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
role_code: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改",
|
||||
)
|
||||
role_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称")
|
||||
calc_template: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, comment="计提公式模板编码(以代码内模板注册表为准)"
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(String(255), comment="岗位说明")
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="显示排序"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
|
||||
)
|
||||
is_builtin: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="是否内置岗位:1内置(禁删禁停) 0自定义",
|
||||
)
|
||||
159
backend/app/models/param.py
Normal file
159
backend/app/models/param.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""计提参数模型:定义、变更批次与版本条目。
|
||||
|
||||
存储设计(M1 评审定稿):
|
||||
- 参数值按**生效日期时点化**存储(逐项版本条目,append-only),核算按业务
|
||||
日期回溯取值(对照表 #11:按回款到账日匹配参数版本),禁读「当前值」算历史
|
||||
- 「版本」由变更批次承载:一次保存 = 一个批次 = 一条变更记录;条目逐项留痕
|
||||
(旧值/新值),满足 #28 版本管理与留痕(需规 2.3.1 四)
|
||||
- 值一律为 Fraction 可解析字符串(如 "4/360",禁 0.011111),解析不经 float
|
||||
- **不设 (param_id, dimension_value, effective_date) 唯一键**:同日多次修正
|
||||
是合法留痕,解析取 (effective_date, id) 最新一条;唯一键会把同日二次变更
|
||||
逼成覆盖写、丢失中间值
|
||||
- 批次与条目均为 append-only(仅 create_time、无 update_time,同 login_logs
|
||||
先例):带 ON UPDATE 的列会让「未改也被刷新」造成留痕假象
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class ParamDefinition(Base, TimestampMixin):
|
||||
"""计提参数定义表。目录元数据(类型/单位/维度/取值域),随迁移种子初始化。"""
|
||||
|
||||
__tablename__ = "param_definitions"
|
||||
__table_args__ = mysql_table_args("计提参数定义表")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
param_code: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, unique=True, comment="参数编码,如 salesperson.rate.slope"
|
||||
)
|
||||
param_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称")
|
||||
param_group: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径",
|
||||
)
|
||||
value_type: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT"
|
||||
)
|
||||
unit: Mapped[str | None] = mapped_column(String(16), comment="展示单位:‰/%/元/天,无单位为空")
|
||||
dimension_type: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
server_default="NONE",
|
||||
comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE",
|
||||
)
|
||||
options_text: Mapped[str | None] = mapped_column(
|
||||
String(255), comment="枚举选项(竖线分隔),仅 ENUM 使用"
|
||||
)
|
||||
default_value: Mapped[str | None] = mapped_column(
|
||||
String(64), comment="系统默认值(Fraction 可解析字符串)"
|
||||
)
|
||||
value_min: Mapped[str | None] = mapped_column(String(64), comment="取值下限(含),校验用")
|
||||
value_max: Mapped[str | None] = mapped_column(String(64), comment="取值上限(含),校验用")
|
||||
description: Mapped[str | None] = mapped_column(String(255), comment="口径说明(配置页展示)")
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="显示排序"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
|
||||
)
|
||||
|
||||
|
||||
class ParamChangeBatch(Base):
|
||||
"""参数变更批次表(append-only)。
|
||||
|
||||
一次提交 = 一个批次;版本号(如 P20261012-01)供对比与追溯引用。
|
||||
未生效批次可整批撤回(删批次与条目);已生效条目禁改禁删。
|
||||
"""
|
||||
|
||||
__tablename__ = "param_change_batches"
|
||||
__table_args__ = mysql_table_args("参数变更批次表")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
version_no: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, unique=True, comment="版本号,如 P20261012-01"
|
||||
)
|
||||
effective_date: Mapped[date] = mapped_column(
|
||||
Date, nullable=False, comment="生效日期(业务日期口径)"
|
||||
)
|
||||
change_type: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, comment="生效方式:1立即生效 2指定日期生效"
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="变更说明")
|
||||
operator_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
comment="操作人ID(账号删除后置空)",
|
||||
)
|
||||
operator_name: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, comment="操作人用户名(冗余固化)"
|
||||
)
|
||||
item_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="变更参数项数"
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||
)
|
||||
|
||||
|
||||
class ParamValue(Base):
|
||||
"""参数版本条目表(append-only)。
|
||||
|
||||
解析规则:按 (param_id, dimension_value) 取 effective_date <= 业务日期 的
|
||||
ORDER BY effective_date DESC, id DESC 首条(同日多次变更取最新)。
|
||||
dimension_value 用空串而非 NULL:MySQL 组合索引对 NULL 不去重,空串语义更稳。
|
||||
"""
|
||||
|
||||
__tablename__ = "param_values"
|
||||
__table_args__ = mysql_table_args(
|
||||
"参数版本条目表(append-only,按生效日期时点化取值)",
|
||||
Index(
|
||||
"idx_param_values_param_id_dimension_value_effective_date",
|
||||
"param_id",
|
||||
"dimension_value",
|
||||
"effective_date",
|
||||
),
|
||||
Index("idx_param_values_effective_date", "effective_date"),
|
||||
Index("idx_param_values_batch_id", "batch_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
param_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("param_definitions.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
comment="参数定义ID(param_definitions.id)",
|
||||
)
|
||||
dimension_value: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
server_default=text("''"),
|
||||
comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值",
|
||||
)
|
||||
effective_date: Mapped[date] = mapped_column(
|
||||
Date, nullable=False, comment="生效日期(按业务日期时点化取值)"
|
||||
)
|
||||
value_text: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, comment="参数值(Fraction 可解析字符串/枚举码/开关)"
|
||||
)
|
||||
old_value_text: Mapped[str | None] = mapped_column(String(64), comment="变更前值(留痕)")
|
||||
batch_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("param_change_batches.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
comment="变更批次ID(param_change_batches.id)",
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||
)
|
||||
88
backend/app/repositories/business_role_repository.py
Normal file
88
backend/app/repositories/business_role_repository.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""业务岗位角色数据访问。
|
||||
|
||||
本层只 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()
|
||||
@ -32,7 +32,21 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
|
||||
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
|
||||
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
|
||||
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。
|
||||
_SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key")
|
||||
# phone/mobile/id_card 系 CLAUDE.md「日志禁输出手机号/身份证」红线的键名口径 ——
|
||||
# 带分隔符的号码会穿过文本正则,必须在这里按整值抹除
|
||||
_SENSITIVE_KEY_PARTS = (
|
||||
"password",
|
||||
"passwd",
|
||||
"pwd",
|
||||
"secret",
|
||||
"token",
|
||||
"app_key",
|
||||
"access_key",
|
||||
"phone",
|
||||
"mobile",
|
||||
"id_card",
|
||||
"idcard",
|
||||
)
|
||||
|
||||
_MASK = "***"
|
||||
|
||||
|
||||
227
backend/app/repositories/param_repository.py
Normal file
227
backend/app/repositories/param_repository.py
Normal file
@ -0,0 +1,227 @@
|
||||
"""计提参数数据访问。
|
||||
|
||||
本层只 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)]
|
||||
90
backend/app/schemas/business_role.py
Normal file
90
backend/app/schemas/business_role.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""业务岗位角色的请求/响应模型。"""
|
||||
|
||||
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="状态筛选")
|
||||
238
backend/app/schemas/param.py
Normal file
238
backend/app/schemas/param.py
Normal file
@ -0,0 +1,238 @@
|
||||
"""计提参数管理的请求/响应模型。
|
||||
|
||||
字段命名与 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="按维度值筛选,空为全部"
|
||||
)
|
||||
287
backend/app/services/business_role_service.py
Normal file
287
backend/app/services/business_role_service.py
Normal file
@ -0,0 +1,287 @@
|
||||
"""业务岗位角色管理业务编排。
|
||||
|
||||
需求 #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)
|
||||
72
backend/app/services/calc_templates.py
Normal file
72
backend/app/services/calc_templates.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""计提公式模板注册表(代码内有限集合)。
|
||||
|
||||
一期不引入规则引擎的落地方式:公式骨架固定(本表枚举),变化的是参数值
|
||||
(系统设置维护)。岗位角色(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
|
||||
92
backend/app/services/formula.py
Normal file
92
backend/app/services/formula.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""计提公式纯函数(无 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
|
||||
304
backend/app/services/param_registry.py
Normal file
304
backend/app/services/param_registry.py
Normal file
@ -0,0 +1,304 @@
|
||||
"""计提参数目录注册表。
|
||||
|
||||
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
|
||||
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
|
||||
值一律以 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
|
||||
664
backend/app/services/param_service.py
Normal file
664
backend/app/services/param_service.py
Normal file
@ -0,0 +1,664 @@
|
||||
"""计提参数服务:时点化取值、版本管理(变更/撤回/历史/对比)、计算预览与导出。
|
||||
|
||||
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()
|
||||
@ -34,6 +34,7 @@ dependencies = [
|
||||
# 图形验证码(纯 Python,依赖 Pillow)
|
||||
"captcha>=0.6",
|
||||
"pillow>=11.0",
|
||||
"openpyxl>=3.1.5",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@ -81,6 +81,17 @@ 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 冒烟另验证提交/生效/撤回/对比/导出/预览全链路)。
|
||||
|
||||
## 三、运行记录
|
||||
|
||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||
@ -114,9 +125,9 @@ uv run pytest tests/integration -q # 仅集成
|
||||
|
||||
| 模块 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 |
|
||||
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
|
||||
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
|
||||
| 基础数据管理 | 未开始 | 需求已澄清(2026-10-12);依赖 T+ 对接方式确认 |
|
||||
| 业务数据管理 | 未开始 | 需求已澄清(28 项裁决见 `docs/问题与解决方案对照表_v2.xlsx`) |
|
||||
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
|
||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||
|
||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||
|
||||
@ -62,7 +62,20 @@ class TestSerializeRequestData:
|
||||
def test_手机号被脱敏(self) -> None:
|
||||
text = _serialize_request_data({"phone": "13812345678"}) or ""
|
||||
assert "13812345678" not in text
|
||||
assert "138****5678" 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
|
||||
|
||||
def test_超长中文按字节边界截断且无残字(self) -> None:
|
||||
"""75000 字节(25000 个汉字)必然超限:
|
||||
|
||||
131
backend/tests/unit/test_formula.py
Normal file
131
backend/tests/unit/test_formula.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""公式纯函数测试。
|
||||
|
||||
守的是:算钱公式的精确性与边界 —— 一旦按 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
|
||||
@ -19,6 +19,10 @@ EXPECTED_TABLES = {
|
||||
"role_permissions",
|
||||
"login_logs",
|
||||
"operation_logs",
|
||||
"business_roles",
|
||||
"param_definitions",
|
||||
"param_change_batches",
|
||||
"param_values",
|
||||
}
|
||||
|
||||
|
||||
|
||||
154
backend/tests/unit/test_param_registry.py
Normal file
154
backend/tests/unit/test_param_registry.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""参数注册表与公式模板注册表测试。
|
||||
|
||||
守的是:注册表是参数种子数据与运行期校验的唯一来源 —— 一旦编码或默认值
|
||||
与迁移种子漂移,参数解析会取到错误口径;一旦取值校验失守,
|
||||
畸形值(如 "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")
|
||||
95
backend/tests/unit/test_param_snapshot.py
Normal file
95
backend/tests/unit/test_param_snapshot.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""参数快照(时点化取值)测试。
|
||||
|
||||
守的是:覆盖链与兜底 —— 一旦取错维度(如企业户取到市政阈值行)
|
||||
或漏兜底,核算会用错口径或直接崩溃;同日多次变更必须取最新留痕。
|
||||
仓储 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
23
backend/uv.lock
generated
@ -221,6 +221,15 @@ 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"
|
||||
@ -378,6 +387,18 @@ 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"
|
||||
@ -407,6 +428,7 @@ dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "captcha" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "openpyxl" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pwdlib", extra = ["bcrypt"] },
|
||||
{ name = "pydantic" },
|
||||
@ -433,6 +455,7 @@ 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" },
|
||||
|
||||
@ -16,6 +16,10 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 删除顺序遵循外键依赖:先删子表
|
||||
DROP TABLE IF EXISTS `param_values`;
|
||||
DROP TABLE IF EXISTS `param_change_batches`;
|
||||
DROP TABLE IF EXISTS `param_definitions`;
|
||||
DROP TABLE IF EXISTS `business_roles`;
|
||||
DROP TABLE IF EXISTS `operation_logs`;
|
||||
DROP TABLE IF EXISTS `role_permissions`;
|
||||
DROP TABLE IF EXISTS `user_roles`;
|
||||
@ -167,6 +171,89 @@ CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id);
|
||||
|
||||
CREATE INDEX idx_operation_logs_username ON operation_logs (username);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 业务岗位角色表(V3 迁移)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE business_roles (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改',
|
||||
role_name VARCHAR(64) NOT NULL COMMENT '岗位名称',
|
||||
calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)',
|
||||
description VARCHAR(255) COMMENT '岗位说明',
|
||||
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
|
||||
is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_business_roles PRIMARY KEY (id),
|
||||
CONSTRAINT uk_business_roles_role_code UNIQUE (role_code)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 计提参数定义表(V3 迁移)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE param_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope',
|
||||
param_name VARCHAR(64) NOT NULL COMMENT '参数名称',
|
||||
param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径',
|
||||
value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT',
|
||||
unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空',
|
||||
dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE',
|
||||
options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用',
|
||||
default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)',
|
||||
value_min VARCHAR(64) COMMENT '取值下限(含),校验用',
|
||||
value_max VARCHAR(64) COMMENT '取值上限(含),校验用',
|
||||
description VARCHAR(255) COMMENT '口径说明(配置页展示)',
|
||||
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_param_definitions PRIMARY KEY (id),
|
||||
CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 参数变更批次表(V3 迁移)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE param_change_batches (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01',
|
||||
effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)',
|
||||
change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效',
|
||||
remark VARCHAR(255) COMMENT '变更说明',
|
||||
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
|
||||
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
|
||||
item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_param_change_batches PRIMARY KEY (id),
|
||||
CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 参数版本条目表(V3 迁移,append-only)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE param_values (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)',
|
||||
dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '',
|
||||
effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)',
|
||||
value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)',
|
||||
old_value_text VARCHAR(64) COMMENT '变更前值(留痕)',
|
||||
batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_param_values PRIMARY KEY (id),
|
||||
CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_param_values_batch_id ON param_values (batch_id);
|
||||
|
||||
CREATE INDEX idx_param_values_effective_date ON param_values (effective_date);
|
||||
|
||||
CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 字典数据:角色(与 V1 迁移中的初始化数据一致)
|
||||
-- ---------------------------------------------------------------------------
|
||||
@ -174,6 +261,57 @@ INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin
|
||||
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决)
|
||||
-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES
|
||||
(1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1),
|
||||
(2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1),
|
||||
(3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1);
|
||||
|
||||
INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES
|
||||
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
|
||||
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
|
||||
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2),
|
||||
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3),
|
||||
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4),
|
||||
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5),
|
||||
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6),
|
||||
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7),
|
||||
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8),
|
||||
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9),
|
||||
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10),
|
||||
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11),
|
||||
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12),
|
||||
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13),
|
||||
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14),
|
||||
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
|
||||
(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||
(2, 2, 'salesperson', '2026-02-17', '4/360', 1),
|
||||
(3, 3, 'salesperson', '2026-02-17', '4', 1),
|
||||
(4, 4, 'salesperson', '2026-02-17', '360', 1),
|
||||
(5, 5, 'sales_manager', '2026-08-13', '12.5', 1),
|
||||
(6, 6, 'sales_manager', '2026-08-13', '5', 1),
|
||||
(7, 7, '', '2026-02-17', '2', 1),
|
||||
(8, 7, '', '2026-08-13', '3', 1),
|
||||
(9, 8, 'info_officer', '2026-08-13', '1', 1),
|
||||
(10, 9, '', '2026-01-01', '10', 1),
|
||||
(11, 10, '', '2026-01-01', '80', 1),
|
||||
(12, 11, '', '2026-01-01', '60', 1),
|
||||
(13, 12, '', '2026-01-01', 'FULL', 1),
|
||||
(14, 13, 'municipal_water', '2026-02-17', '30', 1),
|
||||
(15, 13, 'rail_transit', '2026-02-17', '20', 1),
|
||||
(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1),
|
||||
(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1),
|
||||
(18, 15, '', '2026-01-01', '18000', 1),
|
||||
(19, 16, '', '2026-01-01', '3000', 1);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
|
||||
-- ---------------------------------------------------------------------------
|
||||
@ -182,4 +320,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('a45a8087831b');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('bfbda9c3191d');
|
||||
|
||||
153
db/migration/V3__add_business_roles_and_params.sql
Normal file
153
db/migration/V3__add_business_roles_and_params.sql
Normal file
@ -0,0 +1,153 @@
|
||||
-- Running upgrade a45a8087831b -> bfbda9c3191d
|
||||
|
||||
CREATE TABLE business_roles (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改',
|
||||
role_name VARCHAR(64) NOT NULL COMMENT '岗位名称',
|
||||
calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)',
|
||||
description VARCHAR(255) COMMENT '岗位说明',
|
||||
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
|
||||
is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_business_roles PRIMARY KEY (id),
|
||||
CONSTRAINT uk_business_roles_role_code UNIQUE (role_code)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE param_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope',
|
||||
param_name VARCHAR(64) NOT NULL COMMENT '参数名称',
|
||||
param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径',
|
||||
value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT',
|
||||
unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空',
|
||||
dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE',
|
||||
options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用',
|
||||
default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)',
|
||||
value_min VARCHAR(64) COMMENT '取值下限(含),校验用',
|
||||
value_max VARCHAR(64) COMMENT '取值上限(含),校验用',
|
||||
description VARCHAR(255) COMMENT '口径说明(配置页展示)',
|
||||
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_param_definitions PRIMARY KEY (id),
|
||||
CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE param_change_batches (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01',
|
||||
effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)',
|
||||
change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效',
|
||||
remark VARCHAR(255) COMMENT '变更说明',
|
||||
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
|
||||
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
|
||||
item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_param_change_batches PRIMARY KEY (id),
|
||||
CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE param_values (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)',
|
||||
dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '',
|
||||
effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)',
|
||||
value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)',
|
||||
old_value_text VARCHAR(64) COMMENT '变更前值(留痕)',
|
||||
batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_param_values PRIMARY KEY (id),
|
||||
CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_param_values_batch_id ON param_values (batch_id);
|
||||
|
||||
CREATE INDEX idx_param_values_effective_date ON param_values (effective_date);
|
||||
|
||||
CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date);
|
||||
|
||||
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1);
|
||||
|
||||
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1);
|
||||
|
||||
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1);
|
||||
|
||||
INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES (1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (1, 1, 'salesperson', '2026-02-17', '10', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (2, 2, 'salesperson', '2026-02-17', '4/360', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (3, 3, 'salesperson', '2026-02-17', '4', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (4, 4, 'salesperson', '2026-02-17', '360', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (5, 5, 'sales_manager', '2026-08-13', '12.5', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (6, 6, 'sales_manager', '2026-08-13', '5', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (7, 7, '', '2026-02-17', '2', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (8, 7, '', '2026-08-13', '3', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (9, 8, 'info_officer', '2026-08-13', '1', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (10, 9, '', '2026-01-01', '10', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (11, 10, '', '2026-01-01', '80', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (12, 11, '', '2026-01-01', '60', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (13, 12, '', '2026-01-01', 'FULL', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (14, 13, 'municipal_water', '2026-02-17', '30', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (15, 13, 'rail_transit', '2026-02-17', '20', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (18, 15, '', '2026-01-01', '18000', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1);
|
||||
|
||||
UPDATE alembic_version SET version_num='bfbda9c3191d' WHERE alembic_version.version_num = 'a45a8087831b';
|
||||
|
||||
58
db/queries/计提参数.sql
Normal file
58
db/queries/计提参数.sql
Normal file
@ -0,0 +1,58 @@
|
||||
-- ============================================================================
|
||||
-- 计提参数查询留档(M1 参数体系)
|
||||
--
|
||||
-- 时点化取值核心规则:按 (param_id, dimension_value) 分组,取
|
||||
-- effective_date <= :biz_date 的 ORDER BY effective_date DESC, id DESC 首条
|
||||
-- (同日多次变更取最新一条,不设唯一键以保证完整留痕)。
|
||||
-- 参数总量约百行级,全量快照一次取回后在应用层按覆盖链
|
||||
-- (角色维度 > 产品类别维度 > 默认行)选择,无需在 SQL 层做维度回退。
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. 全量快照:某业务日期下每个(参数, 维度值)的最新生效值
|
||||
-- 窗口函数版(MySQL 8.0+),供 param_repository.resolve_values 使用
|
||||
SELECT t.param_code, t.dimension_value, t.value_text, t.effective_date
|
||||
FROM (
|
||||
SELECT pd.param_code,
|
||||
pv.dimension_value,
|
||||
pv.value_text,
|
||||
pv.effective_date,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY pv.param_id, pv.dimension_value
|
||||
ORDER BY pv.effective_date DESC, pv.id DESC
|
||||
) AS rn
|
||||
FROM param_values pv
|
||||
JOIN param_definitions pd ON pd.id = pv.param_id
|
||||
WHERE pv.effective_date <= :biz_date
|
||||
AND pd.status = 1
|
||||
) t
|
||||
WHERE t.rn = 1;
|
||||
|
||||
-- 2. 单参数版本历史(变更留痕,供参数历史接口与导出)
|
||||
SELECT pv.effective_date,
|
||||
pv.dimension_value,
|
||||
pv.old_value_text,
|
||||
pv.value_text,
|
||||
b.version_no,
|
||||
b.operator_name,
|
||||
b.create_time
|
||||
FROM param_values pv
|
||||
JOIN param_change_batches b ON b.id = pv.batch_id
|
||||
WHERE pv.param_id = :param_id
|
||||
ORDER BY pv.effective_date DESC, pv.id DESC;
|
||||
|
||||
-- 3. 区间变更条目(对比视图辅助:应用层对两日期各执行查询 1 后逐项 diff,
|
||||
-- 本查询列出区间内实际发生过的变更,供变更记录分页与导出)
|
||||
SELECT pd.param_code,
|
||||
pd.param_name,
|
||||
pv.dimension_value,
|
||||
pv.old_value_text,
|
||||
pv.value_text,
|
||||
pv.effective_date,
|
||||
b.version_no,
|
||||
b.operator_name
|
||||
FROM param_values pv
|
||||
JOIN param_definitions pd ON pd.id = pv.param_id
|
||||
JOIN param_change_batches b ON b.id = pv.batch_id
|
||||
WHERE pv.effective_date > :from_date
|
||||
AND pv.effective_date <= :to_date
|
||||
ORDER BY pv.effective_date, pv.id;
|
||||
@ -1,11 +1,13 @@
|
||||
# 前端
|
||||
|
||||
> 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`(会话状态)。当前完成登录链路与系统管理页面(用户/角色/日志查询),业务模块页面待接入。
|
||||
> 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`。
|
||||
|
||||
## 职责
|
||||
|
||||
- 登录页与图形验证码交互
|
||||
- 会话状态管理与路由守卫
|
||||
- 岗位角色管理页(模板绑定)、计提参数配置页(分组目录/批次提交/版本对比/导出/计算预览)
|
||||
- 首页 Bento 仪表盘(M1 空态设计;业务上线后接入真实统计,图表用 ECharts)
|
||||
- 后续:基础数据、业务数据、绩效核算、报表等业务页面
|
||||
|
||||
## 技术栈与版本约束
|
||||
@ -136,3 +138,13 @@ 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` 传管理员密码。
|
||||
|
||||
145
frontend/e2e/param_management.py
Normal file
145
frontend/e2e/param_management.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""计提参数管理端到端测试(需要容器、后端、前端都在运行)。
|
||||
|
||||
覆盖链路(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())
|
||||
90
frontend/src/api/businessRoles.ts
Normal file
90
frontend/src/api/businessRoles.ts
Normal file
@ -0,0 +1,90 @@
|
||||
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' })
|
||||
}
|
||||
208
frontend/src/api/params.ts
Normal file
208
frontend/src/api/params.ts
Normal file
@ -0,0 +1,208 @@
|
||||
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 })
|
||||
}
|
||||
@ -51,6 +51,10 @@ 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 但业务码异常的情况
|
||||
@ -61,7 +65,19 @@ service.interceptors.response.use(
|
||||
},
|
||||
(error: AxiosError<ApiResult>) => {
|
||||
const status = error.response?.status
|
||||
const message = error.response?.data?.message ?? '网络异常,请稍后重试'
|
||||
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 响应,保持兜底提示 */
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
// 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403,
|
||||
|
||||
@ -2,32 +2,49 @@
|
||||
|
||||
:root {
|
||||
/*
|
||||
* 品牌色:与登录页视觉体系同一套(原为登录页 scoped 局部变量,布局框架
|
||||
* 与系统管理页面共用后提升为全局 token)。
|
||||
* 金色分两档:亮金只用于深色底、深金只用于浅色底,保证两处对比度都达标。
|
||||
* 品牌色(2026-10-12 视觉方向:企业级扁平 · 轻质感 · 靛蓝点缀)。
|
||||
* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。
|
||||
* 设计预览与规范:docs/design/视觉方向预览.html
|
||||
*/
|
||||
--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;
|
||||
--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;
|
||||
|
||||
/*
|
||||
* Element Plus 主色 = 赭金(浅色底配白字约 6.2:1)。
|
||||
* 旧 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 公式「主色与白色混合」预先算好的静态值 ——
|
||||
* 不用 color-mix(),避免依赖较新的浏览器特性。
|
||||
*/
|
||||
--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;
|
||||
--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;
|
||||
}
|
||||
|
||||
* {
|
||||
@ -46,8 +63,8 @@ body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
background-color: #f5f7fa;
|
||||
color: var(--ys-ink);
|
||||
background-color: var(--ys-mist);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
|
||||
@ -24,26 +24,42 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 深色底上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
|
||||
* 中性灰侧栏上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
|
||||
* 后者与组件库规则同权重,受样式注入顺序影响而不稳定。
|
||||
* 激活色用亮金(深色底专用档,见 index.css 的金色两档约定)。
|
||||
* 激活项 = 白底浮起(微渐变 + 内高光 + 分层阴影)+ 左侧 2px 靛蓝条。
|
||||
*/
|
||||
.app-menu {
|
||||
--el-menu-bg-color: transparent;
|
||||
--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;
|
||||
--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;
|
||||
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.app-menu :deep(.el-menu-item.is-active) {
|
||||
background-color: rgb(201 162 39 / 12%);
|
||||
font-weight: 600;
|
||||
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);
|
||||
}
|
||||
|
||||
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
||||
color: var(--ys-gold-bright);
|
||||
color: var(--ys-indigo);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -42,6 +42,7 @@ 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>
|
||||
@ -96,6 +97,7 @@ 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>
|
||||
@ -110,27 +112,39 @@ async function handleLogout(): Promise<void> {
|
||||
.app-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, var(--ys-ink-900) 0%, var(--ys-ink-700) 100%);
|
||||
/* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */
|
||||
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
|
||||
border-right: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 18px 20px 14px;
|
||||
gap: 3px;
|
||||
padding: 18px 20px 12px;
|
||||
}
|
||||
|
||||
.app-brand__name {
|
||||
font-family: 'Songti SC', SimSun, serif;
|
||||
font-size: 18px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--ys-paper);
|
||||
font-family: var(--ys-font-brand);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 4px;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.app-brand__sub {
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
color: rgb(250 250 247 / 55%);
|
||||
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));
|
||||
}
|
||||
|
||||
.app-aside__menu {
|
||||
@ -143,8 +157,8 @@ async function handleLogout(): Promise<void> {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: linear-gradient(180deg, #ffffff, #fdfdfe);
|
||||
border-bottom: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
.app-header__left {
|
||||
@ -173,7 +187,7 @@ async function handleLogout(): Promise<void> {
|
||||
}
|
||||
|
||||
.app-header__menu-toggle:focus-visible {
|
||||
outline: 2px solid var(--ys-gold-deep);
|
||||
outline: 2px solid var(--ys-indigo);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@ -190,7 +204,10 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
.app-main {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
/* 雾灰底 + 点阵网格:内容区的秩序与密度 */
|
||||
background-color: var(--ys-mist);
|
||||
background-image: radial-gradient(rgba(28, 35, 51, 0.05) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@ -211,9 +228,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, var(--ys-ink-900) 0%, var(--ys-ink-700) 100%);
|
||||
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -52,11 +52,23 @@ 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: 4 },
|
||||
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -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 的展示一致) */
|
||||
|
||||
@ -1,49 +1,350 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
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()
|
||||
|
||||
/**
|
||||
* 首页:登录链路验证页。
|
||||
* 首页仪表盘(Bento Grid,2026-10-12 视觉方向)。
|
||||
*
|
||||
* 顶部栏(用户信息、退出登录)已由 BasicLayout 提供,本页只保留内容区;
|
||||
* 用户名等字段的展示是端到端脚本的断言对象,调整时需同步 e2e。
|
||||
* M1 阶段:计提参数与岗位配置已就绪(真实数据);绩效/回款/待办等业务卡
|
||||
* 展示空态 —— 业务数据管理上线后接入真实统计(图表用 ECharts 渲染)。
|
||||
* 空态文案遵循全局规范:空态是邀请,说明「这里将出现什么、怎么让它出现」。
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
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)
|
||||
}
|
||||
})()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>登录状态验证通过</span>
|
||||
</template>
|
||||
<div>
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
<p class="page-desc">系统概览 · 计提参数与岗位配置就绪,业务数据管理上线后接入核算统计</p>
|
||||
<span class="waterline"></span>
|
||||
</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">
|
||||
<!-- 绩效总额:业务上线前的空态 -->
|
||||
<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-alert
|
||||
class="dashboard-hint"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="这是登录链路的验证页"
|
||||
description="刷新页面可验证会话恢复(守卫会调用 /me);「系统管理」菜单已接入用户管理、角色管理与日志查询。"
|
||||
/>
|
||||
</el-card>
|
||||
<!-- 回款趋势:业务上线前的空态 -->
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-hint {
|
||||
margin-top: 18px;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -77,53 +77,29 @@ onMounted(refreshCaptcha)
|
||||
|
||||
<template>
|
||||
<div class="login">
|
||||
<!-- 品牌面板:以「水」为意象 —— 公司名衍水,业务是流水之账 -->
|
||||
<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>
|
||||
<!-- 背景装饰:右上涟漪同心圆(「衍水」品牌意象);顶部天光由 .login::before 承担 -->
|
||||
<div class="login-ripple" aria-hidden="true">
|
||||
<i></i><i></i><i></i><i></i>
|
||||
</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>
|
||||
|
||||
<!-- 断行位置显式控制:中文可在任意字间断行,交给浏览器自动换行
|
||||
会把「楚」这样的单字挤到末行 -->
|
||||
<p class="brand-hero">
|
||||
<span>每一笔回款,</span>
|
||||
<span>都算得清楚</span>
|
||||
</p>
|
||||
<p class="brand-stages">回款 · 计提 · 核算 · 发放</p>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 表单面板 -->
|
||||
<section class="panel">
|
||||
<div class="panel-inner">
|
||||
<h2 class="panel-title">登录</h2>
|
||||
<p class="panel-hint">请使用管理员分配的账号</p>
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">欢迎回来</h2>
|
||||
<p class="card-hint">请输入账号密码进入系统</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
size="large"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
@ -173,175 +149,149 @@ onMounted(refreshCaptcha)
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p class="panel-foot">衍水集团 · 内部系统</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="login-foot">© 衍水集团 · 内部系统</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 配色:品牌 token 已提升为全局(见 assets/styles/index.css,布局框架与
|
||||
* 系统管理页面共用),此处只做局部别名,用法与取值均与原先一致。
|
||||
*/
|
||||
/* 视觉体系(2026-10-12 确认):企业级扁平 · 轻质感 · 品牌色点缀 · 微动效 */
|
||||
.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: 1 1 56%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 40px 24px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(158deg, var(--ink-800) 0%, var(--ink-900) 62%, #04141b 100%);
|
||||
background-color: #f2f4f8;
|
||||
/* 点阵网格:秩序与密度 */
|
||||
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* 水下的光:给居中的内容一个落点,否则深色面板会显得空 */
|
||||
.brand::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* 顶部天光:靛蓝微光从上方洒下,给页面空气感 */
|
||||
.login::before {
|
||||
content: '';
|
||||
background: radial-gradient(58% 46% at 32% 46%, rgb(32 116 146 / 26%), transparent 72%);
|
||||
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;
|
||||
}
|
||||
|
||||
.brand-flow {
|
||||
.login-ripple i {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(46, 75, 168, 0.1);
|
||||
}
|
||||
|
||||
.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(2) {
|
||||
inset: 70px;
|
||||
border-color: rgba(46, 75, 168, 0.075);
|
||||
}
|
||||
|
||||
@keyframes flow-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
.login-ripple i:nth-child(3) {
|
||||
inset: 140px;
|
||||
border-color: rgba(46, 75, 168, 0.055);
|
||||
}
|
||||
|
||||
.brand-inner {
|
||||
.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;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 56px clamp(40px, 6vw, 104px);
|
||||
}
|
||||
|
||||
/* 内容成块并限宽:超宽屏下文字块不会被拉散 */
|
||||
.brand-block {
|
||||
max-width: 30rem;
|
||||
/* 登录卡:纸白微渐变 + 半透明描边 + 分层阴影 + 入场上浮 */
|
||||
.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);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
margin: 0;
|
||||
font-family: 'Source Han Serif SC', 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
|
||||
font-size: clamp(36px, 3.6vw, 54px);
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.2em;
|
||||
color: #f2f6f7;
|
||||
}
|
||||
|
||||
.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;
|
||||
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 {
|
||||
@keyframes card-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--text);
|
||||
.card-brand {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-hint {
|
||||
margin: 0 0 30px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
.card-brand__name {
|
||||
font-family: var(--ys-font-brand);
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.34em;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.captcha {
|
||||
@ -370,14 +320,15 @@ onMounted(refreshCaptcha)
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s;
|
||||
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);
|
||||
}
|
||||
|
||||
.captcha__image:hover {
|
||||
border-color: var(--gold-deep);
|
||||
border-color: var(--ys-indigo);
|
||||
}
|
||||
|
||||
.captcha__image img {
|
||||
@ -389,89 +340,54 @@ onMounted(refreshCaptcha)
|
||||
|
||||
.captcha__placeholder {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
margin: 32px 0 0;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
color: #9aa8af;
|
||||
.login-foot {
|
||||
margin: 16px 0 0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* 输入框聚焦态也落在同一套配色上 */
|
||||
.panel :deep(.el-input__wrapper) {
|
||||
--el-input-focus-border-color: var(--gold-deep);
|
||||
/* 输入聚焦落在体系主色上(Element Plus 主色已随全局 token 换为靛蓝) */
|
||||
.login :deep(.el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px rgba(28, 35, 51, 0.13) inset;
|
||||
}
|
||||
|
||||
/* ============ 响应式 ============ */
|
||||
.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: 900px) {
|
||||
@media (max-width: 640px) {
|
||||
.login {
|
||||
flex-direction: column;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 0 0 auto;
|
||||
padding: 36px 26px 32px;
|
||||
.login-card {
|
||||
padding: 26px 24px 28px;
|
||||
}
|
||||
|
||||
.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;
|
||||
/* 窄屏上涟漪出画更多,避免挤压卡片 */
|
||||
.login-ripple {
|
||||
top: -260px;
|
||||
right: -240px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 尊重系统的「减弱动效」偏好:保留终态,去掉过渡 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand-flow__line {
|
||||
stroke-dashoffset: 0;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
opacity: 1;
|
||||
.login-card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
374
frontend/src/views/system/business-roles/index.vue
Normal file
374
frontend/src/views/system/business-roles/index.vue
Normal file
@ -0,0 +1,374 @@
|
||||
<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>
|
||||
998
frontend/src/views/system/params/index.vue
Normal file
998
frontend/src/views/system/params/index.vue
Normal file
@ -0,0 +1,998 @@
|
||||
<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>
|
||||
Loading…
Reference in New Issue
Block a user