Commit 426bbfa4 by luoqi

feat(auth): 模拟登录按宿主分 tab;FRIDAY 数据范围用真实品牌/诊所

两宿主此前挤在一个面板(FRIDAY 只有一条集团级横条),数据概念互相干扰。改为宿主 tab:
- 瑞尔集团(jvs-dw):原客服花名册路径,一字未动
- FRIDAY 市场:身份是造的(该宿主本就无客服花名册),但**数据范围必须真实** ——
  品牌/诊所来自新端点 GET /auth/mock-orgs:结构取 org 树(派生自 patient_transactions),
  名字取 host.sourceUnitNames / clinicNames(派生自摄入源),零硬编码

- 契约:MockLoginRequest 加 orgId(任意 org 节点 = 品牌 GUID / 诊所 id);新增 MockOrgs 响应
- service:listMockOrgs(org 树 + 名字典,有名品牌与多诊所品牌排前);friday 分支支持
  orgScope=[orgId],不传则市场级
- web:FridayPanel(角色 + 市场级 + 真实品牌/诊所两级选择 + 搜索)

验证:FRIDAY tab 显示 19 品牌 / 35 诊所(瑞诚齿科 12 诊所、希尔德 3…);点"瑞诚齿科"
品牌级登录 → 右上角 12 个诊所、召回池由 11,723 收窄到 5 人、患者诊所均属该品牌 ✓;
瑞尔 tab 花名册与登录行为不变。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent 5ac83ffb
import { Body, Controller, Get, Post } from '@nestjs/common'; import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import type { AccessTokenPayload } from '@pac/types'; import type { AccessTokenPayload } from '@pac/types';
...@@ -92,6 +92,14 @@ export class AuthController { ...@@ -92,6 +92,14 @@ export class AuthController {
return { users: this.auth.listMockUsers() }; return { users: this.auth.listMockUsers() };
} }
@Public()
@Get('mock-orgs')
@ApiOperation({ summary: '(dev) 真实组织结构(品牌→诊所),给快速登录选数据范围' })
async mockOrgs(@Query('host') host?: string, @Query('tenant') tenant?: string) {
// 默认 friday(多品牌宿主才需要选品牌/诊所);jvs-dw 走客服花名册路径
return this.auth.listMockOrgs(host || 'friday', tenant || 'friday-market');
}
@Get('session') @Get('session')
@ApiBearerAuth('accessToken') @ApiBearerAuth('accessToken')
@ZodResponse({ status: 200, type: SessionResponseDto }) @ZodResponse({ status: 200, type: SessionResponseDto })
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
type AccessTokenPayload, type AccessTokenPayload,
type ExchangeCodeResponse, type ExchangeCodeResponse,
type MockLoginRequest, type MockLoginRequest,
type MockOrgsResponse,
type MockUser, type MockUser,
type Permission, type Permission,
type RefreshTokenResponse, type RefreshTokenResponse,
...@@ -23,6 +24,7 @@ import { RedisService } from '../../redis/redis.service'; ...@@ -23,6 +24,7 @@ import { RedisService } from '../../redis/redis.service';
import { AlertService } from '../../common/alerting/alert.service'; import { AlertService } from '../../common/alerting/alert.service';
import { parseDurationToSeconds } from './duration'; import { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users'; import { loadMockUsers } from './mock-users';
import { OrgTreeService } from './org-tree';
const CODE_PREFIX = 'pac:exchange-code:'; const CODE_PREFIX = 'pac:exchange-code:';
/// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 —— /// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 ——
...@@ -100,6 +102,7 @@ export class AuthService { ...@@ -100,6 +102,7 @@ export class AuthService {
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly alert: AlertService, private readonly alert: AlertService,
private readonly orgTree: OrgTreeService,
) {} ) {}
/** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */ /** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */
...@@ -588,15 +591,20 @@ export class AuthService { ...@@ -588,15 +591,20 @@ export class AuthService {
} }
const fSub = `mock-friday-${req.role}`; const fSub = `mock-friday-${req.role}`;
const fName = `FRIDAY ${roleNameZh(req.role)}`; const fName = `FRIDAY ${roleNameZh(req.role)}`;
// 数据范围:orgId(真实品牌 GUID / 诊所 id,来自 GET /auth/mock-orgs)优先;
// 否则集团根(全部品牌全部诊所)。⚠️ 身份可造,范围必须落在真实摄入数据上。
const fOrgScope = [req.orgLevel === 'group' || !req.orgId ? 'friday-market' : req.orgId];
const { accessToken, refreshToken } = await this.issueTokens(fridayHost, { const { accessToken, refreshToken } = await this.issueTokens(fridayHost, {
userId: fSub, userId: fSub,
tenantId: 'friday-market', tenantId: 'friday-market',
role: req.role, role: req.role,
orgScope: ['friday-market'], // 集团根 → 展开后 sourceUnits=[]/clinicIds=[] 不限 orgScope: fOrgScope,
dictionary: { clinics: {}, users: { [fSub]: fName } }, // 名字典由 session 从 host.sourceUnitNames / clinicNames 补齐(派生自摄入数据),
// 此处只带造的身份名。
dictionary: { users: { [fSub]: fName } },
}); });
this.logger.log( this.logger.log(
`mock-login: tenant=friday(FRIDAY 市场) role=${req.role} sub=${fSub} orgScope=[friday-market]`, `mock-login: tenant=friday(FRIDAY 市场) role=${req.role} sub=${fSub} orgScope=[${fOrgScope.join(',')}]`,
); );
return { accessToken, refreshToken, expiresIn: this.accessExpiresInSeconds }; return { accessToken, refreshToken, expiresIn: this.accessExpiresInSeconds };
} }
...@@ -676,6 +684,39 @@ export class AuthService { ...@@ -676,6 +684,39 @@ export class AuthService {
} }
/** /**
* **真实**组织结构(品牌 → 诊所)—— 给"快速登录"对话框选数据范围用。
*
* 结构取自 org 树(**从摄入数据 patient_transactions 派生**,零硬编码),
* 名字取自 host.sourceUnitNames / clinicNames(refresh-clinic-names 从摄入源派生)。
* 多品牌宿主(FRIDAY)没有客服花名册 → 身份可造,但数据范围必须落在真实品牌/诊所上。
* mock 关闭时返空。
*/
async listMockOrgs(hostName: string, tenantId: string): Promise<MockOrgsResponse> {
if (!this.isMockLoginEnabled()) return { groupId: tenantId, brands: [] };
const host = await this.prisma.host.findFirst({ where: { name: hostName } });
if (!host) return { groupId: tenantId, brands: [] };
const [tree, names] = await Promise.all([
this.orgTree.getTree(host.id, tenantId),
this.getDisplayNames(host.id),
]);
const brands = (tree.root.children ?? []).map((b) => ({
brandId: b.id,
name: names.brands[b.id] ?? b.id,
clinics: (b.children ?? []).map((c) => ({
clinicId: c.id,
name: names.clinics[c.id] ?? c.id,
})),
}));
// 有名字的品牌排前、诊所多的排前(演示时先看到真实机构)
brands.sort(
(a, b) =>
Number(!!names.brands[b.brandId]) - Number(!!names.brands[a.brandId]) ||
b.clinics.length - a.clinics.length,
);
return { groupId: tenantId, brands };
}
/**
* 客服花名册(给"快速登录"对话框选人用)。 * 客服花名册(给"快速登录"对话框选人用)。
* 派生自 data/jvs-dw/users.json,补诊所中文名(从 MOCK_PRESETS)。mock 关闭时返空。 * 派生自 data/jvs-dw/users.json,补诊所中文名(从 MOCK_PRESETS)。mock 关闭时返空。
*/ */
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { MockUser, UserRole } from '@pac/types'; import type { MockOrgBrand, MockUser, UserRole } from '@pac/types';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
...@@ -11,7 +11,7 @@ import { ...@@ -11,7 +11,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { getMockUsers, mockLogin } from '@/lib/auth-api'; import { getMockOrgs, getMockUsers, mockLogin } from '@/lib/auth-api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** /**
...@@ -62,6 +62,10 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -62,6 +62,10 @@ export function MockLoginDialog({ open }: { open: boolean }) {
const [role, setRole] = useState<UserRole>('staff'); // 登录用的 RBAC 权限(默认员工) const [role, setRole] = useState<UserRole>('staff'); // 登录用的 RBAC 权限(默认员工)
const [tenantFilter, setTenantFilter] = useState<'all' | BrandSlug>('all'); const [tenantFilter, setTenantFilter] = useState<'all' | BrandSlug>('all');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
/// 宿主 tab —— 两宿主数据完全分开,互不影响(jvs-dw 有客服花名册;friday 只有真实品牌/诊所)
const [hostTab, setHostTab] = useState<'jvs-dw' | 'friday'>('jvs-dw');
const [fridayBrands, setFridayBrands] = useState<MockOrgBrand[]>([]);
const [brandQuery, setBrandQuery] = useState('');
useEffect(() => { useEffect(() => {
getMockUsers() getMockUsers()
...@@ -69,6 +73,14 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -69,6 +73,14 @@ export function MockLoginDialog({ open }: { open: boolean }) {
.catch(() => {}); // mock 关闭 / 无文件 → 空 → 走降级网格 .catch(() => {}); // mock 关闭 / 无文件 → 空 → 走降级网格
}, []); }, []);
// FRIDAY 真实组织结构(品牌→诊所,派生自摄入数据);切到该 tab 时按需拉
useEffect(() => {
if (hostTab !== 'friday' || fridayBrands.length > 0) return;
getMockOrgs('friday')
.then((r) => setFridayBrands(r.brands))
.catch(() => {});
}, [hostTab, fridayBrands.length]);
const applyTokens = (r: { accessToken: string; refreshToken: string; expiresIn: number }) => const applyTokens = (r: { accessToken: string; refreshToken: string; expiresIn: number }) =>
setTokens({ accessToken: r.accessToken, refreshToken: r.refreshToken, expiresIn: r.expiresIn }); setTokens({ accessToken: r.accessToken, refreshToken: r.refreshToken, expiresIn: r.expiresIn });
...@@ -118,13 +130,14 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -118,13 +130,14 @@ export function MockLoginDialog({ open }: { open: boolean }) {
} }
}; };
// 第二宿主 FRIDAY 市场:集团级登录(orgScope=[friday-market] → 全部品牌全部诊所) // 第二宿主 FRIDAY 市场:身份是造的(无花名册),但数据范围落在**真实**品牌/诊所上。
const pickFriday = async (roleKey: UserRole) => { // orgId 不传 = 集团级(全部品牌全部诊所)。
const key = `friday:${roleKey}`; const pickFriday = async (roleKey: UserRole, orgId?: string) => {
const key = `friday:${orgId ?? 'group'}:${roleKey}`;
if (busy) return; if (busy) return;
setBusy(key); setBusy(key);
try { try {
const r = await mockLogin({ tenant: 'friday', role: roleKey }); const r = await mockLogin({ tenant: 'friday', role: roleKey, ...(orgId ? { orgId } : {}) });
applyTokens(r); applyTokens(r);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
...@@ -166,7 +179,40 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -166,7 +179,40 @@ export function MockLoginDialog({ open }: { open: boolean }) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{csUsers.length > 0 ? ( {/* 宿主 tab —— 两宿主数据完全隔离(不同 host/tenant/诊所),切换互不影响 */}
<div className="flex gap-1 border-b border-slate-100">
{([
{ key: 'jvs-dw' as const, label: '瑞尔集团', sub: 'jvs-dw · 客服花名册' },
{ key: 'friday' as const, label: 'FRIDAY 市场', sub: 'friday · 多品牌 SaaS' },
]).map((t) => (
<button
key={t.key}
type="button"
onClick={() => setHostTab(t.key)}
className={cn(
'flex flex-col items-start rounded-t-md px-3 py-1.5 text-left transition-colors',
hostTab === t.key
? 'border-b-2 border-teal-600 font-semibold text-teal-700'
: 'text-slate-500 hover:text-slate-800',
)}
>
<span className="text-[12.5px]">{t.label}</span>
<span className="text-[10px] font-normal text-slate-400">{t.sub}</span>
</button>
))}
</div>
{hostTab === 'friday' ? (
<FridayPanel
role={role}
setRole={setRole}
brands={fridayBrands}
query={brandQuery}
setQuery={setBrandQuery}
busy={busy}
onPick={pickFriday}
/>
) : csUsers.length > 0 ? (
<div className="grid gap-3 pt-1"> <div className="grid gap-3 pt-1">
{/* ① 登录权限(角色)*/} {/* ① 登录权限(角色)*/}
<div> <div>
...@@ -200,15 +246,6 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -200,15 +246,6 @@ export function MockLoginDialog({ open }: { open: boolean }) {
> >
数据范围 · <strong>集团级</strong> —— 看<strong>全部品牌全部诊所</strong>(跨品牌,最上一层)。角色沿用上方「{roleNameZh(role)}」。 数据范围 · <strong>集团级</strong> —— 看<strong>全部品牌全部诊所</strong>(跨品牌,最上一层)。角色沿用上方「{roleNameZh(role)}」。
</button> </button>
{/* 第二宿主 FRIDAY 市场:集团级专用(多品牌 SaaS,mock 不做品牌/客服级);诊所名未摄入显示 id */}
<button
type="button"
onClick={() => pickFriday(role)}
disabled={!!busy}
className="mt-2 w-full rounded-md border border-dashed border-teal-300 bg-teal-50/40 px-2.5 py-1.5 text-left text-[11.5px] text-teal-800 transition-colors hover:border-teal-400 disabled:opacity-50"
>
宿主 · <strong>FRIDAY 市场</strong>(第二宿主,集团级)—— 看全部品牌全部诊所。角色沿用上方「{roleNameZh(role)}」。
</button>
</div> </div>
{/* ② 选客服 */} {/* ② 选客服 */}
...@@ -336,3 +373,141 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -336,3 +373,141 @@ export function MockLoginDialog({ open }: { open: boolean }) {
</Dialog> </Dialog>
); );
} }
// ──────────────────────────────────────────────
// FridayPanel — 第二宿主(FRIDAY 市场)登录面板
//
// 与瑞尔集团面板**数据完全隔离**:不同 host / tenant / 品牌 / 诊所,互不影响。
// 身份:FRIDAY 无客服花名册 → 用造的通用身份(FRIDAY 员工/主管/管理员);
// 数据范围:**必须真实** —— 品牌与诊所来自 GET /auth/mock-orgs(org 树派生自摄入数据,
// 名字来自 host.sourceUnitNames / clinicNames,同样派生自摄入源)。
// ──────────────────────────────────────────────
function FridayPanel({
role,
setRole,
brands,
query,
setQuery,
busy,
onPick,
}: {
role: UserRole;
setRole: (r: UserRole) => void;
brands: MockOrgBrand[];
query: string;
setQuery: (v: string) => void;
busy: string | null;
onPick: (role: UserRole, orgId?: string) => void;
}) {
const q = query.trim();
const filtered = q
? brands
.map((b) => ({
...b,
clinics: b.clinics.filter((c) => c.name.includes(q)),
}))
.filter((b) => b.name.includes(q) || b.clinics.length > 0)
: brands;
const totalClinics = brands.reduce((n, b) => n + b.clinics.length, 0);
return (
<div className="grid gap-3 pt-1">
{/* ① 角色(功能权限)*/}
<div>
<div className="mb-1.5 text-[12px] font-medium text-slate-600">
① 角色 · 能做什么(功能权限)
<span className="ml-1 text-[10.5px] font-normal text-slate-400">
FRIDAY 无客服花名册,身份用造的;数据范围下方选真实品牌/诊所
</span>
</div>
<div className="grid grid-cols-3 gap-2">
{ROLES.map((r) => (
<button
key={r.key}
type="button"
onClick={() => setRole(r.key)}
className={cn(
'flex flex-col items-start gap-0.5 rounded-md border px-2.5 py-1.5 text-left transition-all',
role === r.key
? 'border-teal-400 bg-teal-50/60 ring-1 ring-teal-200'
: 'border-slate-100 hover:border-teal-300',
)}
>
<span className="text-[12.5px] font-semibold text-slate-800">{r.nameZh}</span>
<span className="text-[10px] leading-tight text-slate-500">{r.desc}</span>
</button>
))}
</div>
{/* 集团级:全部品牌全部诊所 */}
<button
type="button"
onClick={() => onPick(role)}
disabled={!!busy}
className="mt-2 w-full rounded-md border border-dashed border-amber-300 bg-amber-50/40 px-2.5 py-1.5 text-left text-[11.5px] text-amber-800 transition-colors hover:border-amber-400 disabled:opacity-50"
>
数据范围 · <strong>市场级</strong> —— 看<strong>全部品牌全部诊所</strong>(最上一层)。角色沿用上方「
{roleNameZh(role)}」。
</button>
</div>
{/* ② 真实品牌 / 诊所 */}
<div className="rounded-lg border border-slate-100 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[12px] font-medium text-slate-600">
② 数据范围 · 选<strong>品牌</strong><strong>诊所</strong>(真实摄入数据)+ 上方「
{roleNameZh(role)}」角色
</div>
<span className="text-[10.5px] text-slate-500">
{brands.length} 品牌 · {totalClinics} 诊所
</span>
</div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜品牌 / 诊所名…"
className="mb-2 h-7 w-full rounded-md border border-slate-100 bg-slate-50 px-2 text-[12px] outline-none placeholder:text-slate-400 focus:border-teal-300 focus:bg-white"
/>
<div className="max-h-[280px] space-y-2 overflow-y-auto">
{filtered.length === 0 && (
<div className="py-6 text-center text-[11.5px] text-slate-400">
{brands.length === 0 ? '组织结构加载中 / 无摄入数据' : '没有匹配的品牌或诊所'}
</div>
)}
{filtered.map((b) => (
<div key={b.brandId} className="rounded-md border border-slate-100">
{/* 品牌级:该品牌全部诊所 */}
<button
type="button"
onClick={() => onPick(role, b.brandId)}
disabled={!!busy}
className="flex w-full items-center justify-between gap-2 rounded-t-md bg-slate-50/60 px-2.5 py-1.5 text-left transition-colors hover:bg-teal-50/60 disabled:opacity-50"
>
<span className="truncate text-[12.5px] font-semibold text-slate-800">{b.name}</span>
<span className="flex-none text-[10.5px] text-slate-500">
品牌级 · {b.clinics.length} 诊所
</span>
</button>
{/* 诊所级 */}
<div className="flex flex-wrap gap-1 px-2.5 py-1.5">
{b.clinics.map((c) => (
<button
key={c.clinicId}
type="button"
onClick={() => onPick(role, c.clinicId)}
disabled={!!busy}
className="rounded-full border border-slate-100 px-2 py-0.5 text-[11px] text-slate-600 transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700 disabled:opacity-50"
>
{c.name}
</button>
))}
{b.clinics.length === 0 && (
<span className="text-[10.5px] text-slate-400">该品牌暂无诊所数据</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
...@@ -4,6 +4,7 @@ import type { ...@@ -4,6 +4,7 @@ import type {
ExchangeCodeResponse, ExchangeCodeResponse,
MockLoginRequest, MockLoginRequest,
MockLoginResponse, MockLoginResponse,
MockOrgsResponse,
MockUsersResponse, MockUsersResponse,
RefreshTokenResponse, RefreshTokenResponse,
SessionResponse, SessionResponse,
...@@ -41,3 +42,10 @@ export async function mockLogin(req: MockLoginRequest): Promise<MockLoginRespons ...@@ -41,3 +42,10 @@ export async function mockLogin(req: MockLoginRequest): Promise<MockLoginRespons
export async function getMockUsers(): Promise<MockUsersResponse> { export async function getMockUsers(): Promise<MockUsersResponse> {
return api.get<MockUsersResponse>('/pac/v1/auth/mock-users', { auth: false }); return api.get<MockUsersResponse>('/pac/v1/auth/mock-users', { auth: false });
} }
/// 真实组织结构(品牌→诊所,派生自摄入数据)— 多品牌宿主快速登录选数据范围用
export async function getMockOrgs(host = 'friday'): Promise<MockOrgsResponse> {
return api.get<MockOrgsResponse>(`/pac/v1/auth/mock-orgs?host=${encodeURIComponent(host)}`, {
auth: false,
});
}
...@@ -127,6 +127,10 @@ export const MockLoginRequestSchema = z.strictObject({ ...@@ -127,6 +127,10 @@ export const MockLoginRequestSchema = z.strictObject({
/// 数据范围层级(org-scope):'group'=集团(看全部品牌全部诊所,sourceUnits=[] 不限)。 /// 数据范围层级(org-scope):'group'=集团(看全部品牌全部诊所,sourceUnits=[] 不限)。
/// 不给:有 userExternalId=诊所级,否则=品牌级(整个 tenant 品牌)。 /// 不给:有 userExternalId=诊所级,否则=品牌级(整个 tenant 品牌)。
orgLevel: z.enum(['group']).optional(), orgLevel: z.enum(['group']).optional(),
/// 直接指定 org 节点 id(品牌 GUID / 诊所 id)作数据范围 —— 多品牌宿主(FRIDAY)用:
/// 它没有客服花名册,身份是造的,但**品牌/诊所必须是真实摄入数据**(见 GET /auth/mock-orgs)。
/// 给了 orgId 时 orgScope=[orgId];与 orgLevel 互斥(orgLevel=group 优先)。
orgId: z.string().min(1).optional(),
}); });
export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>; export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>;
...@@ -156,6 +160,31 @@ export const MockUsersResponseSchema = z.object({ users: z.array(MockUserSchema) ...@@ -156,6 +160,31 @@ export const MockUsersResponseSchema = z.object({ users: z.array(MockUserSchema)
export type MockUsersResponse = z.infer<typeof MockUsersResponseSchema>; export type MockUsersResponse = z.infer<typeof MockUsersResponseSchema>;
// ============================================================= // =============================================================
// Mock Orgs (GET /pac/v1/auth/mock-orgs) ⭐ 开发 / 试部署用
// =============================================================
//
// 给"快速登录"对话框的**真实**组织结构(品牌 → 诊所),从摄入数据派生的 org 树 +
// host 名字典(sourceUnitNames / clinicNames)。多品牌宿主(FRIDAY)没有客服花名册,
// 身份可造,但数据范围必须落在真实品牌/诊所上 —— 本端点提供可选项。
export const MockOrgClinicSchema = z.object({
clinicId: z.string(),
name: z.string(),
});
export const MockOrgBrandSchema = z.object({
/// 品牌节点 id(= patients.source_unit;多品牌宿主是 GUID)
brandId: z.string(),
name: z.string(),
clinics: z.array(MockOrgClinicSchema),
});
export type MockOrgBrand = z.infer<typeof MockOrgBrandSchema>;
export const MockOrgsResponseSchema = z.object({
/// 集团(租户)节点 id — 集团级登录传它
groupId: z.string(),
brands: z.array(MockOrgBrandSchema),
});
export type MockOrgsResponse = z.infer<typeof MockOrgsResponseSchema>;
// =============================================================
// JWT payload — server-only; never sent to host // JWT payload — server-only; never sent to host
// ============================================================= // =============================================================
......
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