Files
track-LICA/backend/alembic/versions/l1m2n3o4p5q6_add_business_groups.py
duxingchen cfcfca7269 feat(分组权限): 业务分组模型 + DataScope 判定 + 列表接口接入
第一阶段:模型 → 判定 → /auth/me → 列表。统计接口与前端管理页随后。

1) 模型与迁移(head 从 k1l2m3n4o5p6 推进到 l1m2n3o4p5q6)
   · business_groups        组定义,parent_id 表达「大组 > 小组」
   · business_group_phases  可见范围,独立成表以支持多选 —— 需求要求
                            「范围可配置、不要写死」,单列存不下多个 phase
   · business_group_members 成员,一人可属多组(这是「同时看生产+维修」的实现)
   只建表、不写种子数据,所以可以先部署代码再建组。

2) DataScope 判定模块(app/services/data_scope_service.py)
   全仓库唯一的权限谓词来源,业务代码里不准再出现 lifecycle_phase 过滤。
   两条红线照抄部门隔离的教训:
   · None(不限) 与 frozenset()(空) 语义相反,绝不共用一个哨兵值
   · 空集合必须显式 false() —— SQLAlchemy 对 in_(()) 生 成 IN (NULL),
     一旦退化成不过滤就是全量泄漏
   解析优先级:SUPER_ADMIN 硬放行(不可被分组覆盖)
             > 显式分组(分组优先于角色)
             > 未分组 SUPERVISOR 默认全厂
             > 未分组普通用户

3) 过渡期开关 DATA_SCOPE_UNGROUPED(默认 ALL)
   直接上严格模式会让所有未分组工人当场看不到自己的任务、现场停摆。
   默认 ALL 先放行并打 WARNING 记录「谁还没分组」,配好组后再改 NONE。

4) /auth/me 返回 scope,phase 中文标签由服务端下发
   —— 前端已有两份 phase 词表副本,不再加第三份。

5) 列表接口接入
   · get_all_products:过滤加在 offset/limit 之前(其下有 6 段基于 product_ids
     的批量预计算,过滤晚了等于算完再丢)
   · get_all_tasks:谓词进【共享 filters】,保证 count 与 select 两条独立语句
     同时生效,否则 total 与实际页不一致、移动端 hasMore 判断跟着错
   · 扫码 get_product_by_serial 刻意不过滤,理由写死在 docstring 里

实测:空 scope 生成 false、受限 scope 生成 JOIN + IN 谓词;
/products 返回 2 条、/tasks 的 total 与 returned 一致。
2026-09-21 17:07:46 +08:00

95 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""add_business_groups
Revision ID: l1m2n3o4p5q6
Revises: k1l2m3n4o5p6
Create Date: 2026-09-21
业务分组(business_groups / business_group_phases / business_group_members)
--------------------------------------------------------------------------
LICA 部门内部再分一层数据范围:生产大组看生产制造、维修大组看售后回流。
组与 lifecycle_phase 直接映射,不新造业务概念 —— 这套阶段语义系统里早就有了
(app/core/lifecycle.py),此前只用于展示与校验,没有权限含义。
三张表的分工:
· business_groups 组定义,parent_id 表达「大组 > 小组」。子组默认继承
父组的可见范围,所以生产大组配一次就够。
· business_group_phases 组的可见范围,独立成表是为了支持多选 —— 需求明确
要求「范围可配置、不要写死」,单列存不下多个 phase。
· business_group_members 成员,一人可属多组(这是「让某人同时看生产+维修」
的实现方式,不需要任何特殊逻辑)。
本迁移只建表,**不写入任何种子数据**。
一个组都没有时,resolve_data_scope 会走到「未分组」分支,配合
config.DATA_SCOPE_UNGROUPED 开关决定行为 —— 所以可以先部署代码、再建组,
分批上线。初始化入口见 backend/scripts/seed_business_groups.py。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "l1m2n3o4p5q6"
down_revision: Union[str, None] = "k1l2m3n4o5p6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"business_groups",
sa.Column("id", sa.Integer(), autoincrement=True, primary_key=True,
comment="分组ID(数字,对外稳定不变;显示名可改而引用不变)"),
sa.Column("parent_id", sa.Integer(),
sa.ForeignKey("business_groups.id", ondelete="CASCADE"),
nullable=True,
comment="上级大组ID(为空=大组;有值=挂在某大组下的小组)"),
sa.Column("name", sa.String(50), nullable=False, unique=True,
comment="分组显示名(可修改)"),
sa.Column("description", sa.String(200), nullable=True, comment="备注说明"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0",
comment="同层排序(升序)"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true",
comment="是否启用。停用后该组所有成员立即退回未分组状态"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, comment="创建时间"),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, comment="最后修改时间"),
)
# 列表页按大组罗列子组,走 parent_id
op.create_index("ix_business_groups_parent_id", "business_groups", ["parent_id"])
op.create_table(
"business_group_phases",
sa.Column("group_id", sa.Integer(),
sa.ForeignKey("business_groups.id", ondelete="CASCADE"),
primary_key=True, comment="分组ID"),
sa.Column("phase", sa.String(20), primary_key=True,
comment="可见的生命周期阶段: PRODUCTION | AFTER_SALES"),
)
op.create_table(
"business_group_members",
sa.Column("id", sa.Integer(), autoincrement=True, primary_key=True),
sa.Column("group_id", sa.Integer(),
sa.ForeignKey("business_groups.id", ondelete="CASCADE"),
nullable=False, comment="所属分组ID"),
sa.Column("user_id", sa.String(64), nullable=False,
comment="成员账号(逻辑外键→MOM sys_user,与 Task.assignee_id 同口径)"),
sa.Column("is_leader", sa.Boolean(), nullable=False, server_default="false",
comment="是否组长(可管理本组成员;数据范围与组员相同)"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, comment="加入时间"),
sa.UniqueConstraint("group_id", "user_id",
name="uq_business_group_members_group_user"),
)
# 每个请求都会按 user_id 反查「我在哪些组」—— 这是热路径,必须建索引
op.create_index("ix_business_group_members_user_id", "business_group_members", ["user_id"])
# 组详情页列成员、以及 CASCADE 删除,走 group_id
op.create_index("ix_business_group_members_group_id", "business_group_members", ["group_id"])
def downgrade() -> None:
op.drop_index("ix_business_group_members_group_id", table_name="business_group_members")
op.drop_index("ix_business_group_members_user_id", table_name="business_group_members")
op.drop_table("business_group_members")
op.drop_table("business_group_phases")
op.drop_index("ix_business_groups_parent_id", table_name="business_groups")
op.drop_table("business_groups")