diff --git a/backend/alembic/versions/79bcc5aa98d4_v6_系统设置_二次分配_审批表与角色参数种子.py b/backend/alembic/versions/79bcc5aa98d4_v6_系统设置_二次分配_审批表与角色参数种子.py new file mode 100644 index 0000000..a66cc12 --- /dev/null +++ b/backend/alembic/versions/79bcc5aa98d4_v6_系统设置_二次分配_审批表与角色参数种子.py @@ -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')") diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index ef6fc85..c361404 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer" # ===== 角色编码(与 db/migration 初始化数据保持一致)===== ROLE_ADMIN = "admin" 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」, @@ -70,6 +83,8 @@ MODULE_BUSINESS = "业务管理" MODULE_SHIPMENT = "发货管理" MODULE_RECEIPT = "回款管理" MODULE_RATING = "客户评级" +MODULE_SYSTEM_SETTINGS = "系统设置" +MODULE_APPROVAL = "审批管理" # ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)===== # 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组 @@ -77,6 +92,7 @@ NO_PREFIX_BUSINESS = "YW" NO_PREFIX_SHIPMENT = "SH" NO_PREFIX_RECEIPT = "HK" NO_PREFIX_PARAM = "P" +NO_PREFIX_APPROVAL = "AP" ACTION_CREATE = "新增" ACTION_UPDATE = "修改" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c8b1493..066c6f8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,8 +9,9 @@ `Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。 """ +from app.models.approval import ApprovalOrder, ApprovalStep 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.customer import Customer, OwnershipChangeLog 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.role import Permission, Role, RolePermission from app.models.shipment import Shipment, ShipmentItem +from app.models.system_setting import SystemSetting from app.models.user import User, UserRole __all__ = [ + "ApprovalOrder", + "ApprovalStep", "Base", "Business", "BusinessItem", @@ -46,8 +50,10 @@ __all__ = [ "Receipt", "Role", "RolePermission", + "SecondaryAllocation", "Shipment", "ShipmentItem", + "SystemSetting", "TimestampMixin", "User", "UserRole", diff --git a/backend/app/models/approval.py b/backend/app/models/approval.py new file mode 100644 index 0000000..bfdace5 --- /dev/null +++ b/backend/app/models/approval.py @@ -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="创建时间" + ) diff --git a/backend/app/models/business.py b/backend/app/models/business.py index 518b254..3d3a891 100644 --- a/backend/app/models/business.py +++ b/backend/app/models/business.py @@ -33,6 +33,58 @@ BUSINESS_STATUS_DONE = 2 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): """业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。""" diff --git a/backend/app/models/system_setting.py b/backend/app/models/system_setting.py new file mode 100644 index 0000000..334e8aa --- /dev/null +++ b/backend/app/models/system_setting.py @@ -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=未上传使用默认)", + ) diff --git a/backend/app/services/param_registry.py b/backend/app/services/param_registry.py index 8063a46..0d3c2b3 100644 --- a/backend/app/services/param_registry.py +++ b/backend/app/services/param_registry.py @@ -256,6 +256,40 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)", 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, + ), ] } diff --git a/backend/app/services/role_service.py b/backend/app/services/role_service.py index dd73912..f86fbb2 100644 --- a/backend/app/services/role_service.py +++ b/backend/app/services/role_service.py @@ -1,17 +1,20 @@ """角色管理业务编排。 -需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善** -(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置, -待业务方给出权限清单后再启用)。 +需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求 +新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批 +节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` / +`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。 保护规则(都是系统自举与授权安全的最低防线,不只是界面体验): -- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖 - 该编码存在,删除会使系统无法自举 +- **内置角色禁删禁停**(`BUILTIN_ROLE_CODES`:admin / marketing_ops / + finance / leader)—— `admin` 被删除会使系统无法自举(cli 建管理员与 + `require_admin` 判定依赖);财务/领导是审批节点准入锚点,被删会让 + 在途审批流失去可操作人 - **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默 剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE` 锁行,堵住「校验与删除之间并发绑定」的窗口 -- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点 +- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点 - 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态), 但**不可再分配给用户**(`user_service._resolve_roles` 拦截) """ @@ -27,8 +30,8 @@ from app.core.constants import ( ACTION_DISABLE, ACTION_ENABLE, ACTION_UPDATE, + BUILTIN_ROLE_CODES, MODULE_ROLE, - ROLE_ADMIN, ) from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError from app.core.response import PageResult @@ -177,10 +180,10 @@ def set_status( 停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态), 但该角色不可再分配给用户(`user_service._resolve_roles` 拦截); - 内置管理员角色不可停用。 + 内置角色不可停用。 :raises NotFoundError: 角色不存在 - :raises ForbiddenError: 试图停用内置管理员角色 + :raises ForbiddenError: 试图停用内置角色 """ role = role_repository.get_by_id(session, role_id) verb = "启用" if status == 1 else "禁用" @@ -198,8 +201,8 @@ def set_status( if role is None: raise NotFoundError("角色不存在") - if role.role_code == ROLE_ADMIN and status == 0: - raise ForbiddenError("内置管理员角色不可停用") + if role.role_code in BUILTIN_ROLE_CODES and status == 0: + raise ForbiddenError("内置角色不可停用") role.status = status out = _to_out(role, _user_count(session, role_id)) @@ -217,12 +220,12 @@ def delete_role( ) -> None: """删除角色。 - 内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。 + 内置角色不可删除(admin 与三个审批职能角色,见 BUILTIN_ROLE_CODES);仍有关联用户的角色不可删除(提示先解除绑定)。 查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把 新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。 :raises NotFoundError: 角色不存在 - :raises ForbiddenError: 试图删除内置管理员角色 + :raises ForbiddenError: 试图删除内置角色 :raises BadRequestError: 角色下仍有绑定的用户 """ role = role_repository.get_by_id(session, role_id, for_update=True) @@ -239,8 +242,8 @@ def delete_role( if role is None: raise NotFoundError("角色不存在") - if role.role_code == ROLE_ADMIN: - raise ForbiddenError("内置管理员角色不可删除") + if role.role_code in BUILTIN_ROLE_CODES: + raise ForbiddenError("内置角色不可删除") # user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限 user_count = _user_count(session, role_id) diff --git a/backend/tests/integration/test_param_management.py b/backend/tests/integration/test_param_management.py index cd17dbc..5cc0e52 100644 --- a/backend/tests/integration/test_param_management.py +++ b/backend/tests/integration/test_param_management.py @@ -86,9 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: 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"] - assert len(items) == 16 + assert len(items) == 18 groups = {item["param_group"] for item in items} assert groups == { "业务员个人户", @@ -98,6 +99,7 @@ class TestCatalog: "支付环节", "产品阈值", "月工资口径", + "审批开关", } def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None: diff --git a/backend/tests/integration/test_schema_sync.py b/backend/tests/integration/test_schema_sync.py index c633331..78721f0 100644 --- a/backend/tests/integration/test_schema_sync.py +++ b/backend/tests/integration/test_schema_sync.py @@ -129,7 +129,7 @@ class TestSchemaSync: 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: migration_sql = "\n".join( path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql")) diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index 6b4b7fd..23f6cf6 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -20,6 +20,10 @@ EXPECTED_TABLES = { "login_logs", "operation_logs", "business_roles", + "system_settings", + "approval_orders", + "approval_steps", + "business_secondary_allocations", "param_definitions", "param_change_batches", "param_values", diff --git a/backend/tests/unit/test_param_registry.py b/backend/tests/unit/test_param_registry.py index 6c7c596..b13dfe3 100644 --- a/backend/tests/unit/test_param_registry.py +++ b/backend/tests/unit/test_param_registry.py @@ -18,7 +18,7 @@ from app.services.param_registry import ( validate_and_normalize, ) -# 与 db/migration/V3 迁移种子保持一致的 16 项编码 +# 与 db/migration 迁移种子保持一致的 18 项编码(V3 十六项 + V6 审批开关两项) EXPECTED_CODES = { "salesperson.rate.start", "salesperson.rate.slope", @@ -36,14 +36,16 @@ EXPECTED_CODES = { "salary.basic.mode", "probation.annual.threshold", "probation.salary.basic", + "approval.secondary_allocation.enabled", + "approval.monthly_settlement.enabled", } class TestRegistry: """注册表元数据完整性(与迁移种子同源)。""" - def test_注册表共16项(self) -> None: - assert len(PARAM_REGISTRY) == 16 + def test_注册表共18项(self) -> None: + assert len(PARAM_REGISTRY) == 18 def test_编码集合与迁移种子一致(self) -> None: assert set(PARAM_REGISTRY) == EXPECTED_CODES @@ -61,10 +63,27 @@ class TestRegistry: continue if spec.value_type in {"DECIMAL", "INT"}: 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": assert spec.options, f"{spec.code} 为 ENUM 但无选项" 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: for spec in PARAM_REGISTRY.values(): if spec.value_min is not None: diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 885769f..e5b6076 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -16,6 +16,10 @@ 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_change_batches`; 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); +-- 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 业务裁决) -- 显式指定 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), (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); +(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 (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), (17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 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 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护) @@ -663,6 +679,72 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order ('C', 60, 74, 3, '合格'), ('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 成为空操作 -- --------------------------------------------------------------------------- @@ -671,4 +753,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 ('8efc189b131f'); +INSERT INTO alembic_version (version_num) VALUES ('79bcc5aa98d4'); diff --git a/db/migration/V6__add_settings_allocations_approvals.sql b/db/migration/V6__add_settings_allocations_approvals.sql new file mode 100644 index 0000000..f07d227 --- /dev/null +++ b/db/migration/V6__add_settings_allocations_approvals.sql @@ -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'; +