feat(migration): V6 系统设置/二次分配/审批表与角色参数种子

- 新增 system_settings(公司信息单行)/ business_secondary_allocations(二次
  分配明细,比例不强制合计满额,分摊计算待澄清另批)/ approval_orders+
  approval_steps(三级审批状态机,开关关闭自动通过留痕)
- roles 种子新增营销后勤/财务/领导三职能角色;内置保护改 BUILTIN_ROLE_CODES
  白名单(roles 表无 is_builtin 列,不加列)
- PARAM_REGISTRY 首例 BOOL:两个审批开关(16→18 项,种子双写)
- 双轨同步:V6 导出 SQL + init-dev.sql(表/索引/种子/版本号 79bcc5aa98d4)
- 测试同步:EXPECTED_TABLES 四新表、角色种子断言 5 角色、目录断言 18 项
  八分组、BOOL 校验边界;迁移 downgrade 修 1553(只保留 drop_table)并补
  行级种子反向清理

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-15 17:25:23 +08:00
parent 94c49138bf
commit acf87fdfdc
14 changed files with 918 additions and 25 deletions

View File

@ -0,0 +1,436 @@
"""V6 系统设置/二次分配/审批表与角色参数种子
Revision ID: 79bcc5aa98d4
Revises: 8efc189b131f
Create Date: 2026-10-15 15:42:13.467447
"""
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 = "79bcc5aa98d4"
down_revision: str | Sequence[str] | None = "8efc189b131f"
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(
"system_settings",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"company_name",
sa.String(length=64),
nullable=False,
comment="公司名称(登录页与导出页眉展示)",
),
sa.Column(
"logo_path",
sa.String(length=255),
nullable=True,
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
),
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_system_settings")),
comment="系统设置表(公司信息,全系统单行)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"approval_orders",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"order_no",
sa.String(length=32),
nullable=False,
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
),
sa.Column(
"order_type",
mysql.TINYINT(),
nullable=False,
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
),
sa.Column(
"ref_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
),
sa.Column(
"refer_no",
sa.String(length=32),
nullable=False,
comment="引用对象单号(冗余固化,列表页免联查)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_approval_orders_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_orders")),
sa.UniqueConstraint("order_no", name=op.f("uk_approval_orders_order_no")),
comment="审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_approval_orders_type_status", "approval_orders", ["order_type", "status"], unique=False
)
op.create_table(
"approval_steps",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"order_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="审批单ID(approval_orders.id;单删记录随删)",
),
sa.Column(
"node",
mysql.TINYINT(),
nullable=False,
comment="审批节点:0撤回留痕 1财务复核 2领导审核",
),
sa.Column(
"action", mysql.TINYINT(), nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
),
sa.Column(
"opinion", 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(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_approval_steps_operator_id"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["order_id"],
["approval_orders.id"],
name=op.f("fk_approval_steps_order_id"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_steps")),
comment="审批节点记录表(append-only;自动通过/撤回均有留痕)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_approval_steps_order_id", "approval_steps", ["order_id"], unique=False)
op.create_table(
"business_secondary_allocations",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id;业务删除随删)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="参与业务员ID(personnel.id;被方案引用禁删)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="参与业务员姓名(冗余固化,离职后回显有名)",
),
sa.Column(
"ratio",
sa.Numeric(precision=5, scale=2),
nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_business_secondary_allocations_business_id"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_business_secondary_allocations_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")),
comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_business_secondary_allocations_business_id",
"business_secondary_allocations",
["business_id"],
unique=False,
)
op.create_index(
"idx_business_secondary_allocations_personnel_id",
"business_secondary_allocations",
["personnel_id"],
unique=False,
)
# ### end Alembic commands ###
# ---- 种子数据(生产与开发同源;写法沿用 V1/V3 迁移先例)----
# 三个审批职能登录角色(2026-10-15 复核审批补充需求;roles 表无 is_builtin
# 列,内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担)
op.bulk_insert(
sa.table(
"roles",
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
),
[
{
"role_code": "marketing_ops",
"role_name": "营销后勤",
"description": "提交与录入(审批链第一节点:月结/二次分配提交)",
"sort_order": 3,
},
{
"role_code": "finance",
"role_name": "财务",
"description": "复核(审批链第二节点)",
"sort_order": 4,
},
{
"role_code": "leader",
"role_name": "领导",
"description": "审核(审批链第三节点;全览只读)",
"sort_order": 5,
},
],
)
# 审批开关参数定义(param_definitions id 沿用 V3 种子的显式 id 方案接续 17/18)
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 17,
"param_code": "approval.secondary_allocation.enabled",
"param_name": "二次分配审批开关",
"param_group": "审批开关",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
),
"sort_order": 17,
"status": 1,
},
{
"id": 18,
"param_code": "approval.monthly_settlement.enabled",
"param_name": "月度结算审批开关",
"param_group": "审批开关",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
"0=结算生成即生效,1=走财务复核→领导审核审批流"
),
"sort_order": 18,
"status": 1,
},
],
)
# 审批开关联动批次条目(batch_id=1 即 V3 的「初始版本」批次;BOOL 无维度行,
# 生效日期 2026-01-01 对齐其他支付环节种子)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 20,
"param_id": 17,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
},
{
"id": 21,
"param_id": 18,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
},
],
)
# 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
op.bulk_insert(
sa.table(
"system_settings",
sa.column("id", sa.BigInteger),
sa.column("company_name", sa.String),
sa.column("logo_path", sa.String),
),
[
{"id": 1, "company_name": "衍水集团", "logo_path": None},
],
)
def downgrade() -> None:
"""回滚迁移。
整表回滚只保留 `op.drop_table` —— DROP TABLE 会连带删除索引与外键。
autogenerate 原样输出的逐个 `op.drop_index` 会失败:idx_approval_steps_order_id
与 idx_business_secondary_allocations_personnel_id 分别是外键的唯一支撑索引,
单独删除被 MySQL 拒绝(errno 1553),回滚会中断在半途。
删除顺序:先删引用方(approval_steps/二次分配),再删被引用方。
行级种子反向清理:roles/param_definitions/param_values 三表由 V1/V3 建、
本迁移只插行(roles 三角色、参数 17/18 及其版本条目),drop_table
不会带走,须显式 DELETE,否则重放 upgrade 时唯一键冲突。
"""
op.drop_table("business_secondary_allocations")
op.drop_table("approval_steps")
op.drop_table("approval_orders")
op.drop_table("system_settings")
op.execute("DELETE FROM param_values WHERE id IN (20, 21)")
op.execute("DELETE FROM param_definitions WHERE id IN (17, 18)")
op.execute("DELETE FROM roles WHERE role_code IN ('marketing_ops', 'finance', 'leader')")

View File

@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer"
# ===== 角色编码(与 db/migration 初始化数据保持一致)===== # ===== 角色编码(与 db/migration 初始化数据保持一致)=====
ROLE_ADMIN = "admin" ROLE_ADMIN = "admin"
ROLE_USER = "user" ROLE_USER = "user"
# 2026-10-15 复核审批补充需求新增职能登录角色:营销后勤(提交+录入)、
# 财务(复核)、领导(审核)。roles 表无 is_builtin 列,内置保护用代码
# 白名单判定(role_service 两处内置校验改查此集合);
# permissions/role_permissions 细粒度激活待业务权限清单(待澄清 #6)。
ROLE_MARKETING_OPS = "marketing_ops"
ROLE_FINANCE = "finance"
ROLE_LEADER = "leader"
BUILTIN_ROLE_CODES = {
ROLE_ADMIN,
ROLE_MARKETING_OPS,
ROLE_FINANCE,
ROLE_LEADER,
}
# ===== 密码策略 ===== # ===== 密码策略 =====
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」, # bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
@ -70,6 +83,8 @@ MODULE_BUSINESS = "业务管理"
MODULE_SHIPMENT = "发货管理" MODULE_SHIPMENT = "发货管理"
MODULE_RECEIPT = "回款管理" MODULE_RECEIPT = "回款管理"
MODULE_RATING = "客户评级" MODULE_RATING = "客户评级"
MODULE_SYSTEM_SETTINGS = "系统设置"
MODULE_APPROVAL = "审批管理"
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)===== # ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组 # 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
@ -77,6 +92,7 @@ NO_PREFIX_BUSINESS = "YW"
NO_PREFIX_SHIPMENT = "SH" NO_PREFIX_SHIPMENT = "SH"
NO_PREFIX_RECEIPT = "HK" NO_PREFIX_RECEIPT = "HK"
NO_PREFIX_PARAM = "P" NO_PREFIX_PARAM = "P"
NO_PREFIX_APPROVAL = "AP"
ACTION_CREATE = "新增" ACTION_CREATE = "新增"
ACTION_UPDATE = "修改" ACTION_UPDATE = "修改"

View File

@ -9,8 +9,9 @@
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。 `Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
""" """
from app.models.approval import ApprovalOrder, ApprovalStep
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole from app.models.business_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog from app.models.login_log import LoginLog
@ -22,9 +23,12 @@ from app.models.rating import CustomerRatingGrade
from app.models.receipt import Receipt from app.models.receipt import Receipt
from app.models.role import Permission, Role, RolePermission from app.models.role import Permission, Role, RolePermission
from app.models.shipment import Shipment, ShipmentItem from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting
from app.models.user import User, UserRole from app.models.user import User, UserRole
__all__ = [ __all__ = [
"ApprovalOrder",
"ApprovalStep",
"Base", "Base",
"Business", "Business",
"BusinessItem", "BusinessItem",
@ -46,8 +50,10 @@ __all__ = [
"Receipt", "Receipt",
"Role", "Role",
"RolePermission", "RolePermission",
"SecondaryAllocation",
"Shipment", "Shipment",
"ShipmentItem", "ShipmentItem",
"SystemSetting",
"TimestampMixin", "TimestampMixin",
"User", "User",
"UserRole", "UserRole",

View File

@ -0,0 +1,128 @@
"""审批模型:审批单与节点记录(2026-10-15 复核审批补充需求)。
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数体系开关驱动
(`approval.*.enabled` BOOL):关闭=提交即生成「自动通过」节点留痕,
留痕不断链(开发计划 3.2(三));开启=按节点人工审核,一驳全驳。
依赖说明:节点准入按登录角色编码判定(ROLE_FINANCE/ROLE_LEADER),
不动 `permissions`/`role_permissions` 闲置表 —— 细粒度权限清单待业务方
答复(待澄清 #6)后再激活。
ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=2
(月结预留)将指向 ④ 尚未落地的表,外键无法统一指向;单据删除保护由
服务层承担(详见 approval_service)。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 审批单状态(approval_orders.status;流转机见 approval_service)
APPROVAL_STATUS_PENDING_FINANCE = 1 # 待财务复核
APPROVAL_STATUS_PENDING_LEADER = 2 # 待领导审核
APPROVAL_STATUS_APPROVED = 3 # 已通过(终态)
APPROVAL_STATUS_REJECTED = 4 # 已驳回(终态)
APPROVAL_STATUS_WITHDRAWN = 5 # 已撤回(被引用方重新提交时旧在途单注销)
# 节点(approval_steps.node;0 仅用于撤回留痕)
APPROVAL_NODE_FINANCE = 1
APPROVAL_NODE_LEADER = 2
APPROVAL_NODE_WITHDRAW = 0
APPROVAL_NODES = {
APPROVAL_STATUS_PENDING_FINANCE: APPROVAL_NODE_FINANCE,
APPROVAL_STATUS_PENDING_LEADER: APPROVAL_NODE_LEADER,
}
# 动作(approval_steps.action)
APPROVAL_ACTION_APPROVE = 1 # 人工通过
APPROVAL_ACTION_REJECT = 2 # 人工驳回
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
class ApprovalOrder(Base, TimestampMixin):
"""审批单:一张待审对象一条审批流(状态可流转,含 update_time)。"""
__tablename__ = "approval_orders"
__table_args__ = mysql_table_args(
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
Index("idx_approval_orders_type_status", "order_type", "status"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
order_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
)
order_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算(预留)"
)
ref_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
)
refer_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)
class ApprovalStep(Base):
"""审批节点记录表(append-only):每次动作只增不改,构成时间线。
不继承 TimestampMixin(留痕无更新语义,同 business_status_logs 先例)。
"""
__tablename__ = "approval_steps"
__table_args__ = mysql_table_args(
"审批节点记录表(append-only;自动通过/撤回均有留痕)",
Index("idx_approval_steps_order_id", "order_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
order_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("approval_orders.id", ondelete="CASCADE"),
nullable=False,
comment="审批单ID(approval_orders.id;单删记录随删)",
)
node: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="审批节点:0撤回留痕 1财务复核 2领导审核"
)
action: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
)
opinion: 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="操作人用户名(冗余固化;系统动作记「系统」)"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)

View File

@ -33,6 +33,58 @@ BUSINESS_STATUS_DONE = 2
BUSINESS_STATUS_TERMINATED = 3 BUSINESS_STATUS_TERMINATED = 3
class SecondaryAllocation(Base, TimestampMixin):
"""二次分配方案明细表(2026-10-15 补充需求「二次分配方案」)。
口径(AI_CONTEXT 待澄清 #1/#2 未答复前的边界):
- 只出现在合同刚建立时;修改在首次月结前留痕放行,月结后只读(月结在 ④,
终态门槛先行承担「修改不影响已结算月份」)
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
与比例,响应以 hint 提示合计/余额,不做强制校验
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
同一合同的同一参与人仅一行,重复提交在服务层聚合。
"""
__tablename__ = "business_secondary_allocations"
__table_args__ = mysql_table_args(
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="CASCADE"),
nullable=False,
comment="业务ID(businesses.id;业务删除随删)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="参与业务员ID(personnel.id;被方案引用禁删)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)"
)
ratio: Mapped[Decimal] = mapped_column(
Numeric(5, 2),
nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
)
class Business(Base, TimestampMixin, TPlusSyncMixin): class Business(Base, TimestampMixin, TPlusSyncMixin):
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。""" """业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""

View File

@ -0,0 +1,31 @@
"""系统设置模型:公司信息单例行。
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。
"""
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class SystemSetting(Base, TimestampMixin):
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。"""
__tablename__ = "system_settings"
__table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
company_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)"
)
logo_path: Mapped[str | None] = mapped_column(
String(255),
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
)

View File

@ -256,6 +256,40 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)", description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
sort_order=16, sort_order=16,
), ),
ParamSpec(
code="approval.secondary_allocation.enabled",
name="二次分配审批开关",
group="审批开关",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="0",
value_min=None,
value_max=None,
description=(
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
),
sort_order=17,
),
ParamSpec(
code="approval.monthly_settlement.enabled",
name="月度结算审批开关",
group="审批开关",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="0",
value_min=None,
value_max=None,
description=(
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
"0=结算生成即生效,1=走财务复核→领导审核审批流"
),
sort_order=18,
),
] ]
} }

View File

@ -1,17 +1,20 @@
"""角色管理业务编排。 """角色管理业务编排。
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善** 需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置, 新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
待业务方给出权限清单后再启用)。 节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` /
`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验): 保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖 - **内置角色禁删禁停**(`BUILTIN_ROLE_CODES`:admin / marketing_ops /
该编码存在,删除会使系统无法自举 finance / leader)—— `admin` 被删除会使系统无法自举(cli 建管理员与
`require_admin` 判定依赖);财务/领导是审批节点准入锚点,被删会让
在途审批流失去可操作人
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默 - **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE` 剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
锁行,堵住「校验与删除之间并发绑定」的窗口 锁行,堵住「校验与删除之间并发绑定」的窗口
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点 - `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态), - 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
但**不可再分配给用户**(`user_service._resolve_roles` 拦截) 但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
""" """
@ -27,8 +30,8 @@ from app.core.constants import (
ACTION_DISABLE, ACTION_DISABLE,
ACTION_ENABLE, ACTION_ENABLE,
ACTION_UPDATE, ACTION_UPDATE,
BUILTIN_ROLE_CODES,
MODULE_ROLE, MODULE_ROLE,
ROLE_ADMIN,
) )
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
@ -177,10 +180,10 @@ def set_status(
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态), 停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截); 但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
内置管理员角色不可停用。 内置角色不可停用。
:raises NotFoundError: 角色不存在 :raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图停用内置管理员角色 :raises ForbiddenError: 试图停用内置角色
""" """
role = role_repository.get_by_id(session, role_id) role = role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用" verb = "启用" if status == 1 else "禁用"
@ -198,8 +201,8 @@ def set_status(
if role is None: if role is None:
raise NotFoundError("角色不存在") raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN and status == 0: if role.role_code in BUILTIN_ROLE_CODES and status == 0:
raise ForbiddenError("内置管理员角色不可停用") raise ForbiddenError("内置角色不可停用")
role.status = status role.status = status
out = _to_out(role, _user_count(session, role_id)) out = _to_out(role, _user_count(session, role_id))
@ -217,12 +220,12 @@ def delete_role(
) -> None: ) -> None:
"""删除角色。 """删除角色。
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。 内置角色不可删除(admin 与三个审批职能角色,见 BUILTIN_ROLE_CODES);仍有关联用户的角色不可删除(提示先解除绑定)。
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把 查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。 新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
:raises NotFoundError: 角色不存在 :raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图删除内置管理员角色 :raises ForbiddenError: 试图删除内置角色
:raises BadRequestError: 角色下仍有绑定的用户 :raises BadRequestError: 角色下仍有绑定的用户
""" """
role = role_repository.get_by_id(session, role_id, for_update=True) role = role_repository.get_by_id(session, role_id, for_update=True)
@ -239,8 +242,8 @@ def delete_role(
if role is None: if role is None:
raise NotFoundError("角色不存在") raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN: if role.role_code in BUILTIN_ROLE_CODES:
raise ForbiddenError("内置管理员角色不可删除") raise ForbiddenError("内置角色不可删除")
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限 # user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
user_count = _user_count(session, role_id) user_count = _user_count(session, role_id)

View File

@ -86,9 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
class TestCatalog: class TestCatalog:
"""参数目录:种子完整性与时点化回溯。""" """参数目录:种子完整性与时点化回溯。"""
def test_目录共16项且覆盖七个分组(self, client: TestClient, admin: dict) -> None: def test_目录共18项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
"""V6 起含审批开关组(16 计提项 + 2 BOOL 开关)。"""
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"] items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
assert len(items) == 16 assert len(items) == 18
groups = {item["param_group"] for item in items} groups = {item["param_group"] for item in items}
assert groups == { assert groups == {
"业务员个人户", "业务员个人户",
@ -98,6 +99,7 @@ class TestCatalog:
"支付环节", "支付环节",
"产品阈值", "产品阈值",
"月工资口径", "月工资口径",
"审批开关",
} }
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None: def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:

View File

@ -129,7 +129,7 @@ class TestSchemaSync:
class TestSeedData: class TestSeedData:
"""字典数据与版本标记。""" """字典数据与版本标记。"""
@pytest.mark.parametrize("role_code", ["admin", "user"]) @pytest.mark.parametrize("role_code", ["admin", "user", "marketing_ops", "finance", "leader"])
def test_角色初始化数据在两处都存在(self, role_code: str) -> None: def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
migration_sql = "\n".join( migration_sql = "\n".join(
path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql")) path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql"))

View File

@ -20,6 +20,10 @@ EXPECTED_TABLES = {
"login_logs", "login_logs",
"operation_logs", "operation_logs",
"business_roles", "business_roles",
"system_settings",
"approval_orders",
"approval_steps",
"business_secondary_allocations",
"param_definitions", "param_definitions",
"param_change_batches", "param_change_batches",
"param_values", "param_values",

View File

@ -18,7 +18,7 @@ from app.services.param_registry import (
validate_and_normalize, validate_and_normalize,
) )
# 与 db/migration/V3 迁移种子保持一致的 16 项编码 # 与 db/migration 迁移种子保持一致的 18 项编码(V3 十六项 + V6 审批开关两项)
EXPECTED_CODES = { EXPECTED_CODES = {
"salesperson.rate.start", "salesperson.rate.start",
"salesperson.rate.slope", "salesperson.rate.slope",
@ -36,14 +36,16 @@ EXPECTED_CODES = {
"salary.basic.mode", "salary.basic.mode",
"probation.annual.threshold", "probation.annual.threshold",
"probation.salary.basic", "probation.salary.basic",
"approval.secondary_allocation.enabled",
"approval.monthly_settlement.enabled",
} }
class TestRegistry: class TestRegistry:
"""注册表元数据完整性(与迁移种子同源)。""" """注册表元数据完整性(与迁移种子同源)。"""
def test_注册表共16项(self) -> None: def test_注册表共18项(self) -> None:
assert len(PARAM_REGISTRY) == 16 assert len(PARAM_REGISTRY) == 18
def test_编码集合与迁移种子一致(self) -> None: def test_编码集合与迁移种子一致(self) -> None:
assert set(PARAM_REGISTRY) == EXPECTED_CODES assert set(PARAM_REGISTRY) == EXPECTED_CODES
@ -61,10 +63,27 @@ class TestRegistry:
continue continue
if spec.value_type in {"DECIMAL", "INT"}: if spec.value_type in {"DECIMAL", "INT"}:
parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError
elif spec.value_type == "BOOL":
assert spec.default_value in {"0", "1"}, f"{spec.code} 默认值非 0/1"
elif spec.value_type == "ENUM": elif spec.value_type == "ENUM":
assert spec.options, f"{spec.code} 为 ENUM 但无选项" assert spec.options, f"{spec.code} 为 ENUM 但无选项"
assert spec.default_value in spec.options assert spec.default_value in spec.options
def test_BOOL开关_仅接受0与1(self) -> None:
"""V6 首例 BOOL 注册的两个审批开关:合法值与非法值边界。"""
for code in (
"approval.secondary_allocation.enabled",
"approval.monthly_settlement.enabled",
):
spec = PARAM_REGISTRY[code]
assert spec.value_type == "BOOL"
assert validate_and_normalize(spec, "0") == "0"
assert validate_and_normalize(spec, "1") == "1"
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "true")
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "2")
def test_取值域上下限可解析(self) -> None: def test_取值域上下限可解析(self) -> None:
for spec in PARAM_REGISTRY.values(): for spec in PARAM_REGISTRY.values():
if spec.value_min is not None: if spec.value_min is not None:

View File

@ -16,6 +16,10 @@
SET NAMES utf8mb4; SET NAMES utf8mb4;
-- 删除顺序遵循外键依赖:先删子表 -- 删除顺序遵循外键依赖:先删子表
DROP TABLE IF EXISTS `approval_steps`;
DROP TABLE IF EXISTS `approval_orders`;
DROP TABLE IF EXISTS `business_secondary_allocations`;
DROP TABLE IF EXISTS `system_settings`;
DROP TABLE IF EXISTS `param_values`; DROP TABLE IF EXISTS `param_values`;
DROP TABLE IF EXISTS `param_change_batches`; DROP TABLE IF EXISTS `param_change_batches`;
DROP TABLE IF EXISTS `param_definitions`; DROP TABLE IF EXISTS `param_definitions`;
@ -408,6 +412,14 @@ 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); INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2);
-- 2026-10-15 复核审批补充需求:三个职能登录角色(与 V6 迁移种子一致;
-- 内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担)
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('marketing_ops', '营销后勤', '提交与录入(审批链第一节点:月结/二次分配提交)', 3);
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('finance', '财务', '复核(审批链第二节点)', 4);
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('leader', '领导', '审核(审批链第三节点;全览只读)', 5);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决) -- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决)
-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致 -- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致
@ -436,7 +448,9 @@ INSERT INTO param_definitions (id, param_code, param_name, param_group, value_ty
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13), (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), (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), (15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16); (16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16),
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)', 17),
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);0=结算生成即生效,1=走财务复核→领导审核审批流', 18);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
(1, 1, 'salesperson', '2026-02-17', '10', 1), (1, 1, 'salesperson', '2026-02-17', '10', 1),
@ -457,7 +471,9 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1), (16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1),
(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1), (17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1),
(18, 15, '', '2026-01-01', '18000', 1), (18, 15, '', '2026-01-01', '18000', 1),
(19, 16, '', '2026-01-01', '3000', 1); (19, 16, '', '2026-01-01', '3000', 1),
(20, 17, '', '2026-01-01', '0', 1),
(21, 18, '', '2026-01-01', '0', 1);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护) -- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
@ -663,6 +679,72 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
('C', 60, 74, 3, '合格'), ('C', 60, 74, 3, '合格'),
('D', 0, 59, 4, '待改进'); ('D', 0, 59, 4, '待改进');
-- ---------------------------------------------------------------------------
-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批)
-- ---------------------------------------------------------------------------
CREATE TABLE system_settings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)',
logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_system_settings PRIMARY KEY (id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息,全系统单行)' COLLATE utf8mb4_general_ci;
CREATE TABLE approval_orders (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}',
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1',
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_approval_orders PRIMARY KEY (id),
CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no),
CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
CREATE TABLE approval_steps (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
node TINYINT NOT NULL COMMENT '审批节点:0撤回留痕 1财务复核 2领导审核',
action TINYINT NOT NULL COMMENT '动作:1通过 2驳回 3自动通过 4撤回',
opinion VARCHAR(255) COMMENT '审批意见(驳回时填写)',
operator_id BIGINT UNSIGNED COMMENT '操作人ID(自动通过/系统动作置空)',
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化;系统动作记「系统」)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_approval_steps PRIMARY KEY (id),
CONSTRAINT fk_approval_steps_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT fk_approval_steps_order_id FOREIGN KEY(order_id) REFERENCES approval_orders (id) ON DELETE CASCADE
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批节点记录表(append-only;自动通过/撤回均有留痕)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
CREATE TABLE business_secondary_allocations (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
ratio DECIMAL(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
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_secondary_allocations PRIMARY KEY (id),
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id);
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
-- 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
INSERT INTO system_settings (id, company_name, logo_path) VALUES (1, '衍水集团', NULL);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作 -- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -671,4 +753,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('8efc189b131f'); INSERT INTO alembic_version (version_num) VALUES ('79bcc5aa98d4');

View File

@ -0,0 +1,80 @@
-- Running upgrade 8efc189b131f -> 79bcc5aa98d4
CREATE TABLE system_settings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)',
logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_system_settings PRIMARY KEY (id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息,全系统单行)' COLLATE utf8mb4_general_ci;
CREATE TABLE approval_orders (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}',
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1',
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_approval_orders PRIMARY KEY (id),
CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
CREATE TABLE approval_steps (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
node TINYINT NOT NULL COMMENT '审批节点:0撤回留痕 1财务复核 2领导审核',
action TINYINT NOT NULL COMMENT '动作:1通过 2驳回 3自动通过 4撤回',
opinion VARCHAR(255) COMMENT '审批意见(驳回时填写)',
operator_id BIGINT UNSIGNED COMMENT '操作人ID(自动通过/系统动作置空)',
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化;系统动作记「系统」)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_approval_steps PRIMARY KEY (id),
CONSTRAINT fk_approval_steps_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT fk_approval_steps_order_id FOREIGN KEY(order_id) REFERENCES approval_orders (id) ON DELETE CASCADE
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批节点记录表(append-only;自动通过/撤回均有留痕)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
CREATE TABLE business_secondary_allocations (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
ratio NUMERIC(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
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_secondary_allocations PRIMARY KEY (id),
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id);
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('marketing_ops', '营销后勤', '提交与录入(审批链第一节点:月结/二次分配提交)', 3);
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('finance', '财务', '复核(审批链第二节点)', 4);
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('leader', '领导', '审核(审批链第三节点;全览只读)', 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, status) VALUES (17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)', 17, 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, status) VALUES (18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);0=结算生成即生效,1=走财务复核→领导审核审批流', 18, 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (20, 17, '', '2026-01-01', '0', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (21, 18, '', '2026-01-01', '0', 1);
INSERT INTO system_settings (id, company_name, logo_path) VALUES (1, '衍水集团', NULL);
UPDATE alembic_version SET version_num='79bcc5aa98d4' WHERE alembic_version.version_num = '8efc189b131f';