Files
track-LICA/backend/app/models/business_group.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

113 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.

"""业务分组 — LICA 部门内部的数据范围隔离(生产大组 / 维修大组)
系统已有 lifecycle_phase生产制造 / 售后回流),但此前**只用于展示与校验**
没有任何权限含义。本模块补上「数据范围」这一层:组决定成员能看到哪个阶段的数据。
为什么要两级(大组 > 小组):
生产大组下可能再分「生产小组」「测试小组」,它们看的是同一片数据(都属生产
阶段),彼此互通;维修大组则与生产隔离。用 parent_id 表达归属,子组默认继承
父组的可见范围 —— 这样「生产大组配一次 PRODUCTION下面的小组都不用再配」。
为什么组存在 Track 库而不是 MOM
组是 Track 自己的业务概念(对应 lifecycle_phaseMOM 里没有对应表,且组名要
能由业务随时改。只有「人」是 String(64) 逻辑外键指向 MOM sys_user。
为什么可见范围是独立的 business_group_phases 表而不是一个字段:
需求明确要求「范围可配置、不要写死」,且一个组可能同时要看生产和售后 ——
单列存不下多选。
"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class BusinessGroup(Base):
"""业务分组定义两级parent_id 为空即大组)"""
__tablename__ = "business_groups"
id: Mapped[int] = mapped_column(
primary_key=True, autoincrement=True,
comment="分组ID(数字,对外稳定不变;显示名可改而引用不变)",
)
parent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("business_groups.id", ondelete="CASCADE"),
nullable=True, index=True,
comment="上级大组ID(为空=大组;有值=挂在某大组下的小组,默认继承其可见范围)",
)
name: Mapped[str] = mapped_column(
String(50), nullable=False, unique=True, comment="分组显示名(可修改,如 生产小组/维修组)",
)
description: Mapped[str | None] = mapped_column(
String(200), nullable=True, comment="备注说明",
)
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0", comment="同层排序(升序)",
)
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
comment="是否启用。⚠️ 停用后该组所有成员立即退回「未分组」状态(等同被移出所有组)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time,
onupdate=get_beijing_time, comment="最后修改时间",
)
def __repr__(self) -> str:
return f"<BusinessGroup {self.id} {self.name}>"
class BusinessGroupPhase(Base):
"""分组的可见范围 —— 该组成员能看到哪些 lifecycle_phase 的数据。
⚠️ 这是「不写死」的落点:给不给某个组看售后,完全由管理员在这里配置,
不在代码里硬编码任何「生产组只能看生产」这类规则。
"""
__tablename__ = "business_group_phases"
group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("business_groups.id", ondelete="CASCADE"),
primary_key=True, comment="分组ID",
)
phase: Mapped[str] = mapped_column(
String(20), primary_key=True,
comment="可见的生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(售后回流)",
)
def __repr__(self) -> str:
return f"<BusinessGroupPhase g={self.group_id} {self.phase}>"
class BusinessGroupMember(Base):
"""分组成员。一人可属多个组 —— 这是「让某人同时看生产+维修」的实现方式。"""
__tablename__ = "business_group_members"
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_business_group_members_group_user"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("business_groups.id", ondelete="CASCADE"),
nullable=False, index=True, comment="所属分组ID",
)
user_id: Mapped[str] = mapped_column(
String(64), nullable=False, index=True,
comment="成员账号(逻辑外键→MOM sys_user与 Task.assignee_id / JWT.username 同口径)",
)
is_leader: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false",
comment="是否组长(可管理本组成员;数据范围与组员相同)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="加入时间",
)
def __repr__(self) -> str:
return f"<BusinessGroupMember g={self.group_id} u={self.user_id}>"