Commit 07a32a03 by luoqi

feat(auth): org 树改递归结构 —— 层级无限(支持多级区域)

之前 OrgTree 是写死的 3 层形状 { groupId, brands:{clinics[]} },塞不进区域层。
改成递归 OrgNode(children 无限嵌套):集团→多级区域→品牌→诊所→…均可。
expand 逻辑不变 —— 过滤永远只落 sourceUnits/clinicIds 两维,中间层只是分组,
展开任意节点 = 走子树收集这两类叶子属性。

- OrgNode 递归类型 + buildTree(通用) ;buildOrgTree(3 层糖)签名/tree.groupId 不变
- sourceUnit 命名空间**向下继承**:scope 到单诊所时带上祖先品牌,
  否则 sourceUnits 退化成空(不限品牌)= 越权看全品牌患者(测试抓出,已修)
- 扁平索引按 id **聚合**(数组),同一品牌跨多个区域时全收,不被 Map 覆盖丢诊所

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 44539c27
/**
* 组织范围(标准数据权限)—— 不建表,内存树 + 登录时展开。
*
* 数据权限 = 用户的「org 范围」(一组组织节点,任意层级:集团/品牌/诊所)。
* 数据权限 = 用户的「org 范围」(一组组织节点,任意层级)。
* 登录时 expandOrgScope() 把 org 范围**展开**成两个过滤维度,塞进 token:
* - sourceUnits:范围覆盖的「患者归属层」(jvs-dw = 品牌)→ 过滤患者粒度
* - clinicIds:范围覆盖的诊所 → 过滤 plan(target_clinic_id)
* 过滤层(patient.sourceUnit / plan.targetClinicId)已实现,这里只做"范围→两维"。
*
* 树本身是 host 配置(通用态放 manifest yaml org_tree;此处先内置 jvs-dw)。
* source_unit/品牌只是树上一层 —— 别的 host 患者按诊所发号时,归属层就是诊所,同套机制。
* ── 树是「无限层级」的递归结构 ────────────────────────────────────────────
* 节点不分固定层数:集团 → (多级区域) → 品牌 → 诊所,甚至更深,都行。
* 因为**过滤永远只落在两类叶子属性**上(sourceUnit / clinicId),中间层(区域等)
* 只是分组——展开任意节点 = 走它的整棵子树,把子树里的 sourceUnit + clinicId 收集起来。
* 所以加多少层区域都不改 expand 逻辑,新层只是多几层 children。
*
* 树本身是 host 配置(通用态从摄入数据/manifest 派生;此处先内置 jvs-dw)。
*/
/** 组织树节点 —— 递归,层数不限。 */
export interface OrgNode {
/** 节点 id(scope ref 用它命中:集团 id / 区域 id / 品牌名 / 诊所 id 都行)。 */
id: string;
/**
* 患者归属命名空间(= patients.source_unit)。只有「发证层」节点带它
* (jvs-dw = 品牌;别的 host 按诊所发号则诊所节点带)。区域/集团等中间层不带。
*/
sourceUnit?: string;
/** 诊所 id。只有叶子诊所节点带。 */
clinicId?: string;
/** 子节点。无限嵌套。 */
children?: OrgNode[];
}
export interface OrgTree {
/** 根节点 id(集团)。scope 命中根 = 不限(看全树)。 */
groupId: string;
/** 品牌(= patients.source_unit)→ 其下诊所 id */
brands: Record<string, { clinics: string[] }>;
root: OrgNode;
}
/**
* 从「品牌 → 诊所 id 列表」构建组织树。**不 hardcode** —— 调用方从已有配置/数据派生:
* - mock/contract:从 MOCK_PRESETS(品牌→诊所)派生;
* - 通用态:从摄入数据派生(clinic→brand = patient_transactions.clinic_id JOIN patients.source_unit)。
* 实现对所有 host 通用,差异只在「树数据」,而树数据 3 层(集团/品牌/诊所)本就在数据里。
* 仅「区域」等数据外的额外层才需 manifest yaml 声明
* 便捷构建器:从「品牌 → 诊所 id 列表」构建 3 层树(集团/品牌/诊所)。**不 hardcode**
* —— 调用方从已有配置/数据派生(mock 从 MOCK_PRESETS;通用态从摄入数据)。
*
* 需要区域等更多层时,直接用 OrgNode 手搓任意深度的 root(见 buildTree),
* 这个 helper 只是最常见 3 层形状的糖
*/
export function buildOrgTree(groupId: string, brandClinics: Record<string, string[]>): OrgTree {
const brands: OrgTree['brands'] = {};
for (const [brand, clinics] of Object.entries(brandClinics)) brands[brand] = { clinics };
return { groupId, brands };
export function buildOrgTree(
groupId: string,
brandClinics: Record<string, string[]>,
): OrgTree {
const root: OrgNode = {
id: groupId,
children: Object.entries(brandClinics).map(([brand, clinics]) => ({
id: brand,
sourceUnit: brand,
children: clinics.map((c) => ({ id: c, clinicId: c })),
})),
};
return { groupId, root };
}
/** 通用构建器:直接给整棵递归树(任意层级,含多级区域)。 */
export function buildTree(root: OrgNode): OrgTree {
return { groupId: root.id, root };
}
export interface ExpandedScope {
/** 患者归属层范围(品牌)。空数组 = 不限(集团级,看全集团所有品牌) */
/** 患者归属层范围(sourceUnit)。空数组 = 不限(看全树所有归属层) */
sourceUnits: string[];
/** 诊所范围。空数组 = 不限(集团级,看全集团所有诊所) */
/** 诊所范围。空数组 = 不限(看全树所有诊所) */
clinicIds: string[];
}
/**
* 把一组 org 节点 ref 展开成 { sourceUnits, clinicIds }。
* ref 可以是任意层级:
* - 集团 id(groupId)→ 不限(sourceUnits=[], clinicIds=[]):看全集团
* - 品牌名(source_unit,如 '瑞尔')→ 该品牌 + 其下全部诊所
* - 诊所 id → 该诊所 + 其所属品牌
* 多个 ref 取并集。集团级一旦出现,即整体不限。
* 收集一棵子树下所有的 sourceUnit / clinicId(到累加器)。
* sourceUnit(命名空间)**向下继承**:诊所叶子自身不带 sourceUnit,但归属于其上的品牌
* —— 用 inheritedSu 把祖先命名空间带下来,否则 scope 到单诊所会丢品牌、退化成不限品牌(越权)。
*/
function collectSubtree(
node: OrgNode,
inheritedSu: string | undefined,
sourceUnits: Set<string>,
clinicIds: Set<string>,
): void {
const su = node.sourceUnit ?? inheritedSu;
if (su) sourceUnits.add(su);
if (node.clinicId) clinicIds.add(node.clinicId);
for (const child of node.children ?? []) collectSubtree(child, su, sourceUnits, clinicIds);
}
/**
* 把一组 org 节点 ref 展开成 { sourceUnits, clinicIds }。ref 可是树上**任意层级**:
* - 根(集团 id)→ 不限(sourceUnits=[], clinicIds=[])
* - 任意中间层(如区域 / 多级区域 / 品牌)→ 该节点整棵子树的 sourceUnit + clinic
* - 诊所叶子 → 该诊所 + 其所属命名空间
* 多个 ref 取并集。命中根即整体不限。未知 ref 当裸 clinicId 透传。
*/
export function expandOrgScope(tree: OrgTree, refs: string[]): ExpandedScope {
if (refs.length === 0) return { sourceUnits: [], clinicIds: [] }; // 空范围 = 不限(集团级)
if (refs.length === 0) return { sourceUnits: [], clinicIds: [] }; // 空范围 = 不限
const clinicToBrand = new Map<string, string>();
for (const [brand, b] of Object.entries(tree.brands)) {
for (const c of b.clinics) clinicToBrand.set(c, brand);
}
// 扁平化:id → [{ 节点, 继承的命名空间 }]。同 id 可出现多处(如一个品牌跨多个区域),
// 按数组**聚合**全收 —— 用 Map 直接覆盖会静默丢掉同名节点的其余出现。
const index = new Map<string, Array<{ node: OrgNode; su: string | undefined }>>();
(function walk(n: OrgNode, inheritedSu: string | undefined) {
const su = n.sourceUnit ?? inheritedSu;
(index.get(n.id) ?? index.set(n.id, []).get(n.id)!).push({ node: n, su });
for (const c of n.children ?? []) walk(c, su);
})(tree.root, undefined);
const sourceUnits = new Set<string>();
const clinicIds = new Set<string>();
for (const ref of refs) {
if (ref === tree.groupId) {
return { sourceUnits: [], clinicIds: [] }; // 集团级 → 整体不限
}
const brand = tree.brands[ref];
if (brand) {
// 品牌级
sourceUnits.add(ref);
for (const c of brand.clinics) clinicIds.add(c);
continue;
return { sourceUnits: [], clinicIds: [] }; // 命中根 → 整体不限
}
const ownerBrand = clinicToBrand.get(ref);
if (ownerBrand) {
// 诊所级
sourceUnits.add(ownerBrand);
clinicIds.add(ref);
const hits = index.get(ref);
if (hits) {
// 任意层级 → 把每处出现的子树都展开(带各自祖先命名空间)
for (const h of hits) collectSubtree(h.node, h.su, sourceUnits, clinicIds);
continue;
}
// 未知 ref:当诊所 id 透传(品牌未知则不加 sourceUnit,靠 clinic 圈)
clinicIds.add(ref);
clinicIds.add(ref); // 未知 ref:当裸诊所 id 透传
}
return { sourceUnits: [...sourceUnits], clinicIds: [...clinicIds] };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment