Files
CloudHome-Ops/lib/services/org_service.dart

748 lines
28 KiB
Dart
Raw Normal View History

import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:image_picker/image_picker.dart';
import '../models/org_node.dart';
import '../models/store_config.dart';
import '../models/store_info.dart';
import '../utils/session_org_scope.dart';
import '../utils/store_local_cache.dart';
import 'api_client.dart';
import 'upload_service.dart';
/// 组织与门店管理服务:组织树、门店、设备分组查询。
///
/// 对接真实「爱视后台」/api/sys/org/* 与 /api/sys/store/* 接口。
/// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。
class OrgService {
OrgService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
// ================ 组织管理 ================
/// 获取组织树(全量树形结构,当前用户可见范围)。
///
/// GET /sys/org/tree
///
/// 注:后端该接口在部分部署下会返回**全量**组织树,不做数据权限收敛,
/// 因此这里在客户端按 [SessionOrgScope] 把树裁剪到「当前登录用户所属组织
/// 子树」(含自身与全部后代)。[SessionOrgScope] 未启用(管理员 / orgId 为空)
/// 时原样返回,行为与后端保持一致。
Future<List<OrgNode>> getOrgTree() async {
final response = await _dio.get<dynamic>('/sys/org/tree');
final list = _extractList(response.data);
final tree = list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
return _scopeTree(tree);
}
/// 获取组织列表(分页)。
///
/// GET /sys/org/list?page=&limit=
Future<List<OrgNode>> getOrgList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/sys/org/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
}
/// 获取组织扁平列表(不分页,用于下拉选择)。
///
/// GET /sys/org/flatList
///
/// 命中 [SessionOrgScope] 时,仅保留当前用户组织子树内的节点,
/// 避免下拉选项出现其他组织的越权数据。
Future<List<OrgNode>> getOrgFlatList() async {
final response = await _dio.get<dynamic>('/sys/org/flatList');
final list = _extractList(response.data);
final all = list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
final scope = SessionOrgScope.instance;
if (!scope.enabled) return all;
final ids = await scopedOrgIdSet();
if (ids.isEmpty) return all;
return all.where((n) => ids.contains(n.id)).toList();
}
/// 获取组织下拉选项(同扁平列表,语义区分)。
///
/// GET /sys/org/select
///
/// 同样受 [SessionOrgScope] 收敛,仅返回当前用户组织子树内的节点。
Future<List<OrgNode>> getOrgSelect() async {
final response = await _dio.get<dynamic>('/sys/org/select');
final list = _extractList(response.data);
final all = list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
final scope = SessionOrgScope.instance;
if (!scope.enabled) return all;
final ids = await scopedOrgIdSet();
if (ids.isEmpty) return all;
return all.where((n) => ids.contains(n.id)).toList();
}
/// 获取组织详情。
///
/// GET /sys/org/info/{id}
Future<OrgNode> getOrgInfo(String id) async {
final response = await _dio.get<dynamic>('/sys/org/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '组织信息加载失败:$id');
}
return OrgNode.fromJson(data);
}
/// 获取渠道列表(type=1 的组织节点)。
///
/// GET /sys/org/channels
Future<List<OrgNode>> getOrgChannels() async {
final response = await _dio.get<dynamic>('/sys/org/channels');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
}
/// 获取渠道下的代理商树(渠道 → 代理商 → 门店)。
///
/// GET /sys/org/channelAgentTree?channelId={channelId}
///
/// 注:与 Web 后台 [org.ts] 的 [getChannelAgentTree] 严格一致——channelId 走
/// **查询参数**而非路径段(早期误写成 /channelAgentTree/{id} 会导致后端取不到
/// 渠道、代理树加载为空)。
Future<List<OrgNode>> getChannelAgentTree(String channelId) async {
final response = await _dio.get<dynamic>(
'/sys/org/channelAgentTree',
queryParameters: {'channelId': channelId},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
}
/// 获取「渠道商管理」统一树(渠道 → 代理/子代理)。
///
/// 后端无单一渠道商全量树接口,此处组合两个既有接口:
/// 1. GET /sys/org/channels 拉顶层渠道列表;
/// 2. 对每个渠道 GET /sys/org/channelAgentTree?channelId=xxx 拉其下代理/子代理树。
///
/// **降级策略**:若 /sys/org/channels 失败(部分部署下该端点可能返回服务端异常
/// 或权限错误),自动回退到 GET /sys/org/tree(全量组织树,已在门店列表中验证可用),
/// 从中提取 type=1(渠道)与 type=2(代理)节点及其子树,保证页面不白屏。
///
/// 单渠道 agentTree 请求失败不影响整体(该渠道降级为无子节点),其余渠道正常展示。
/// 结果按渠道 orderNo 升序、渠道内子节点递归按 orderNo 升序,便于稳定展示。
Future<List<OrgNode>> getChannelTree() async {
// 主路径:/sys/org/channels + /sys/org/channelAgentTree
try {
final channels = await getOrgChannels();
final tree = <OrgNode>[];
for (final channel in channels) {
List<OrgNode> agents = const <OrgNode>[];
try {
agents = await getChannelAgentTree(channel.id);
} catch (_) {
// 单渠道拉取失败:降级为无子节点,继续后续渠道。
}
tree.add(channel.copyWith(children: _sortTree(agents)));
}
tree.sort(_byOrderNo);
if (tree.isNotEmpty) return tree;
} catch (e) {
debugPrint('[OrgService.getChannelTree] /sys/org/channels 主路径失败: $e');
}
// 降级路径:从全量组织树递归提取渠道/代理节点。
try {
final orgTree = await getOrgTree();
final fallback = _collectChannelNodes(orgTree);
if (fallback.isNotEmpty) {
debugPrint(
'[OrgService.getChannelTree] 已从 /sys/org/tree 降级加载 '
'${fallback.length} 个渠道/代理节点',
);
return _sortTree(fallback);
}
} catch (e) {
debugPrint('[OrgService.getChannelTree] 降级路径也失败: $e');
}
// 双路径均失败:抛出原始异常让 UI 展示错误。
throw ApiException(500, '无法加载渠道数据,请检查网络或联系管理员');
}
/// 从组织树中**递归**收集渠道(type=1)/代理(type=2) 节点,保留它们之间的层级关系。
///
/// 平台(type=0)/门店(type=3)/分组(type=4) 等非渠道节点作为「过路节点」被跳过
/// (不单独展示),但其下方若含有渠道/代理则继续下钻——确保即使组织树的
/// 顶层是平台节点(type=0),其下的渠道与代理仍能被正确提取。
///
/// 例:平台 → 渠道A → 代理B → 门店C → 代理D
/// 提取结果:[渠道A(子=[代理B(子=[代理D])])]
static List<OrgNode> _collectChannelNodes(List<OrgNode> nodes) {
final result = <OrgNode>[];
for (final node in nodes) {
if (node.type == 1 || node.type == 2) {
// 渠道/代理:保留自身,递归过滤子孙仅保留渠道/代理支系。
result.add(node.copyWith(children: _collectChannelNodes(node.children)));
} else {
// 非渠道节点:跳过自身,但透传其下方可能含渠道/代理的分支。
if (node.children.isNotEmpty) {
result.addAll(_collectChannelNodes(node.children));
}
}
}
return result;
}
/// 递归按 orderNo 升序排序树(节点及各级子节点)。
static List<OrgNode> _sortTree(List<OrgNode> nodes) {
final sorted = [...nodes]..sort(_byOrderNo);
return sorted
.map((n) => n.copyWith(children: _sortTree(n.children)))
.toList();
}
/// 按 orderNo 升序的比较器(orderNo 相同再按 id 兜底,保证稳定)。
static int _byOrderNo(OrgNode a, OrgNode b) {
if (a.orderNo != b.orderNo) return a.orderNo.compareTo(b.orderNo);
return a.id.compareTo(b.id);
}
/// 新增门店 / 组织。
///
/// POST /sys/org/save,body **不带 id/orgId**(带 id 会走 insert 分支,
/// 报「数据库中已存在该记录」;只带 orgId 会被忽略而误建新记录)。
/// 门店为 type='STORE',且**必填 province**,否则报「门店省份不能为空」。
/// 返回后端 data 中的 orgId(新增门店时可用于后续创建激活码等)。
Future<String?> createOrg(Map<String, dynamic> data) async {
final payload = Map<String, dynamic>.from(data);
// 新增必须剥离主键,防止后端误判为更新或重复插入。
payload.remove('id');
payload.remove('orgId');
final response = await _dio.post<dynamic>('/sys/org/save', data: payload);
final result = response.data;
if (result is Map<String, dynamic>) {
return result['orgId']?.toString() ?? result['id']?.toString();
}
if (result is String) return result;
return null;
}
/// 修改门店 / 组织。
///
/// POST /sys/org/update,后端**只认 id**(传 orgId 会报「组织不存在」),
/// 因此强制以 id 为主键并剥离 orgId,避免编辑被误判。
/// 返回后端 data 中的 orgId。
Future<String?> updateOrg(Map<String, dynamic> data) async {
final payload = Map<String, dynamic>.from(data);
// 后端 update 仅使用 id;orgId 会被忽略甚至触发「组织不存在」,必须剥离。
final id = (data['id'] ?? data['orgId'])?.toString() ?? '';
payload.remove('orgId');
payload['id'] = id;
final response = await _dio.post<dynamic>('/sys/org/update', data: payload);
final result = response.data;
if (result is Map<String, dynamic>) {
return result['orgId']?.toString() ?? result['id']?.toString();
}
if (result is String) return result;
return null;
}
/// 新增或修改组织(兼容旧调用方,如组织树页面)。
///
/// 注意:后端**未注册** `/sys/org/saveOrUpdate` 路由,调用必返回
/// 「未知异常,请联系管理员」。故按 body 是否携带 id/orgId 自动分流到
/// [createOrg](新增)或 [updateOrg](编辑)。
Future<String?> saveOrUpdateOrg(Map<String, dynamic> data) async {
final isEdit = (data['id'] != null && data['id'].toString().isNotEmpty) ||
(data['orgId'] != null && data['orgId'].toString().isNotEmpty);
return isEdit ? updateOrg(data) : createOrg(data);
}
/// 删除组织(含子节点校验)。
///
/// POST /sys/org/delete/{id}
Future<void> deleteOrg(String id) async {
await _dio.post<dynamic>('/sys/org/delete/$id');
}
// ================ 门店管理 ================
/// 获取当前登录用户可见的门店列表。
///
/// 排序 / 展示需与 Web 后台「门店管理」保持一致:Web 后台门店管理读取的是
/// 组织树(门店专用接口 /sys/store/* 在该账号下多返回 500,不可用),故
/// 第 1 步直接读组织树并递归收集 type=3 门店,同时透传节点的 [OrgNode.createdAt]
/// 作为 [StoreInfo.createTime] 供「建店时间倒序」使用;门店专用接口仅作兜底。
///
/// 回退链(任一成功且非空即用):
/// 1. 组织树 GET /sys/org/tree,递归收集 type=3 门店(透传 createdAt);
/// 2. 门店专用 GET /sys/store/myStores;
/// 3. 登录组织门店 GET /sys/store/listByLoginOrg。
Future<List<StoreInfo>> getMyStores() async {
// 第 1 步:组织树(与 Web 后台门店管理同源,且透传 createdAt)。
try {
final tree = await getOrgTree();
final stores = _collectStores(tree);
if (stores.isNotEmpty) return stores;
} catch (_) {
// 继续回退。
}
// 第 2 步:myStores。
try {
final response = await _dio.get<dynamic>('/sys/store/myStores');
final list = _extractList(response.data);
final stores = list
.whereType<Map<String, dynamic>>()
.map(StoreInfo.fromJson)
.toList();
if (stores.isNotEmpty) return stores;
} catch (_) {
// 继续回退。
}
// 第 3 步:按登录组织查门店。
try {
final stores = await getStoreListByLoginOrg();
if (stores.isNotEmpty) return stores;
} catch (_) {
// 全部失败,返回空。
}
return const <StoreInfo>[];
}
/// 统计门店数量(type=3)。
///
/// 复用 [getMyStores] 的回退链,确保门店总数与门店列表一致,
/// 避免组织树异常时直接返回 0 导致「门店总数=0」的问题。
Future<int> fetchStoreCount() async {
try {
return (await getMyStores()).length;
} catch (_) {
return 0;
}
}
/// 递归把组织树扁平化,并过滤出门店(type=3 或 type='STORE')。
///
/// 新接口 /sys/org/tree 返回的 type 为字符串(CHANNEL/SERVICE_PROVIDER/STORE/GROUP),
/// 经 [OrgNode.fromJson] 已映射为 int,但此处兼容判断字符串更保险。
///
/// 组织树节点通常不携带 [parentId],而是通过 [children] 嵌套表达层级;
/// 因此递归时把当前节点 [id] 作为子节点的 [parentId] 传入,确保门店的
/// [StoreInfo.parentId] 能正确指向其在树中的直接父组织(渠道/代理商)。
static List<StoreInfo> _collectStores(List<OrgNode> nodes, {String parentId = ''}) {
final stores = <StoreInfo>[];
for (final node in nodes) {
final isStore = node.type == 3 || node.type.toString().toUpperCase() == 'STORE';
if (isStore) {
final province = node.province.trim();
final city = node.city.trim();
final address = node.address.trim();
final fullAddress = <String>[];
if (province.isNotEmpty) fullAddress.add(province);
if (city.isNotEmpty) fullAddress.add(city);
if (address.isNotEmpty) fullAddress.add(address);
// 优先使用节点自身携带的 parentId;树接口未返回时,使用递归上下文中的父节点 ID。
final effectiveParentId = node.parentId.isNotEmpty ? node.parentId : parentId;
stores.add(
StoreInfo(
orgId: node.id,
parentId: effectiveParentId.isEmpty ? null : effectiveParentId,
name: node.name,
address: fullAddress.join(''),
contactPhone: node.contactPhone,
activationCode: node.activationCode,
status: 0,
// 透传组织树节点的到期时间(部分部署的 /sys/org/tree 会返回
// expireTime/expire_time;缺失则为 null,列表项将据此显示「无到期时间」)。
expireTime: node.expireTime,
createTime: node.createdAt,
deviceLimit: node.groupLimit,
// 新接口 /sys/org/tree 返回的 themeId(如 35/36/37),
// 转为字符串供前端 StoreThemePresets 匹配或回显。
themeId: node.themeId?.toString(),
),
);
}
if (node.children.isNotEmpty) {
stores.addAll(_collectStores(node.children, parentId: node.id));
}
}
return stores;
}
/// 按组织 ID 获取门店列表。
///
/// GET /sys/store/byOrgId/{orgId}
Future<List<StoreInfo>> getStoresByOrgId(String orgId) async {
final response =
await _dio.get<dynamic>('/sys/store/byOrgId/$orgId');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(StoreInfo.fromJson)
.toList();
}
/// 按门店 ID 获取设备分组列表。
///
/// GET /sys/store/deviceGroups/{storeId}
Future<List<OrgNode>> getDeviceGroupsByStoreId(String storeId) async {
final response =
await _dio.get<dynamic>('/sys/store/deviceGroups/$storeId');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
}
/// 门店搜索(分页,支持多条件筛选)。
///
/// GET /sys/store/search?page=&limit=&orgId=&name=&status=
///
/// 未显式传入 [orgId] 时,自动带上 [SessionOrgScope] 的当前用户组织,
/// 防止未指定组织时越权查询到其他组织的门店([SessionOrgScope] 未启用时
/// 不附加,保留管理员全量查询能力)。
Future<List<StoreInfo>> searchStore({
int page = 1,
int limit = 20,
String? orgId,
String? name,
int? status,
}) async {
final effectiveOrgId = orgId ??
(SessionOrgScope.instance.enabled
? SessionOrgScope.instance.currentOrgId
: null);
final response = await _dio.get<dynamic>(
'/sys/store/search',
queryParameters: {
'page': page,
'limit': limit,
if (effectiveOrgId != null) 'orgId': effectiveOrgId,
if (name != null) 'name': name,
if (status != null) 'status': status,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(StoreInfo.fromJson)
.toList();
}
/// 获取当前登录组织下的门店列表(可按名称模糊查询)。
///
/// GET /sys/store/listByLoginOrg?name=
Future<List<StoreInfo>> getStoreListByLoginOrg({String? name}) async {
final response = await _dio.get<dynamic>(
'/sys/store/listByLoginOrg',
queryParameters: {
if (name != null) 'name': name,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(StoreInfo.fromJson)
.toList();
}
/// 查询门店配置(到期时间 / 禁用状态等)。
///
/// GET /device/storeConfig/info/{orgId}
///
/// 与 Web 后台「门店配置」弹窗同源(screen-front `getStoreConfig`)。
/// 组织树与通用门店列表接口通常不返回 `expireTime`,门店的到期时间必须由此
/// 端点补全——列表/详情均用它做权威兜底。接口异常或无数据返回 [StoreConfig.empty]。
Future<StoreConfig> getStoreConfig(String orgId) async {
try {
final response =
await _dio.get<dynamic>('/device/storeConfig/info/$orgId');
final data = response.data;
if (data is! Map<String, dynamic>) return StoreConfig.empty();
return StoreConfig.fromJson(data);
} catch (_) {
return StoreConfig.empty();
}
}
// ================ 门店扩展字段(前端占位) ================
/// 新增或更新门店(含主题/品牌/省市区等扩展字段)。
///
/// 门店本质上是 type='STORE' 的组织节点。按是否携带 id/orgId 分流:
/// - 新增 → [createOrg](POST /sys/org/save,不带主键,必填 province);
/// - 编辑 → [updateOrg](POST /sys/org/update,只认 id)。
/// 返回后端生成的 orgId(新增门店时可用于创建激活码)。
Future<String?> saveStore(Map<String, dynamic> data) async {
// 真实上报后端(新增/编辑分流到正确路由)。
final isEdit = (data['id'] != null && data['id'].toString().isNotEmpty) ||
(data['orgId'] != null && data['orgId'].toString().isNotEmpty);
final result = isEdit ? updateOrg(data) : createOrg(data);
// 扩展字段写本地缓存:用真实 orgId 回填,避免新建时因 orgId 为空而丢失
// 主题/品牌/省市区等建店配置(Web 端同源,移动端本地兜底回显)。
final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? '';
if (realOrgId.isNotEmpty) {
final ext = Map<String, dynamic>.from(data)..['orgId'] = realOrgId;
await _persistStoreExtension(ext);
}
return result;
}
/// 更新门店(含扩展字段)。
///
/// 固定走 [updateOrg](POST /sys/org/update,只认 id)。
Future<String?> updateStore(Map<String, dynamic> data) async {
final result = updateOrg(data);
final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? '';
if (realOrgId.isNotEmpty) {
final ext = Map<String, dynamic>.from(data)..['orgId'] = realOrgId;
await _persistStoreExtension(ext);
}
return result;
}
/// 设置门店主题皮肤。
///
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setTheme`。
/// 上报失败时异常向外传播,由调用方决定提示策略(门店列表页会静默忽略,
/// 不影响门店本身已保存的结果)。
Future<void> setStoreTheme(String orgId, String themeId) async {
// 本地缓存兜底:即使上报失败,本机回显仍可用。
await StoreLocalCache.instance.saveTheme(orgId, themeId);
await _dio.post<dynamic>(
'/sys/store/setTheme',
data: {'orgId': orgId, 'themeId': themeId},
);
}
/// 设置门店启动图/Logo。
///
/// [splashUrl]/[logoUrl] 可以是:
/// - 远程 URL(http/https 开头)→ 直接上报;
/// - 本地文件路径 → 先经 [UploadService.uploadImage] 上传拿 CDN URL 再上报。
///
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setBranding`。
Future<void> setStoreBranding(
String orgId,
String splashUrl,
String logoUrl,
) async {
// 本地缓存兜底。
await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl);
final upload = UploadService();
Future<String> resolve(String raw) async {
if (raw.isEmpty) return raw;
// 已是远程 URL 直接复用;否则当作本地路径上传拿 CDN URL。
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
return upload.uploadImage(XFile(raw), mediaType: 'image');
}
final splash = await resolve(splashUrl);
final logo = await resolve(logoUrl);
await _dio.post<dynamic>(
'/sys/store/setBranding',
data: {'orgId': orgId, 'splashUrl': splash, 'logoUrl': logo},
);
}
/// 设置门店省市区归属与详细地址。
///
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setRegion`。
Future<void> setRegion(
String orgId,
String provinceCode,
String cityCode,
String districtCode,
String addressDetail,
) async {
await StoreLocalCache.instance.saveRegion(
orgId,
provinceCode,
cityCode,
districtCode,
addressDetail,
);
await _dio.post<dynamic>(
'/sys/store/setRegion',
data: {
'orgId': orgId,
'provinceCode': provinceCode,
'cityCode': cityCode,
'districtCode': districtCode,
'addressDetail': addressDetail,
},
);
}
/// 把门店扩展字段写入本地缓存([data] 缺省字段忽略)。
Future<void> _persistStoreExtension(Map<String, dynamic> data) async {
final orgId = (data['orgId'] ?? data['id'])?.toString() ?? '';
if (orgId.isEmpty) return;
final themeId = data['themeId']?.toString();
if (themeId != null && themeId.isNotEmpty) {
await StoreLocalCache.instance.saveTheme(orgId, themeId);
}
final splashUrl = data['splashUrl']?.toString() ?? '';
final logoUrl = data['logoUrl']?.toString() ?? '';
if (splashUrl.isNotEmpty || logoUrl.isNotEmpty) {
await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl);
}
final provinceCode = data['provinceCode']?.toString() ?? '';
final cityCode = data['cityCode']?.toString() ?? '';
final districtCode = data['districtCode']?.toString() ?? '';
final addressDetail = data['addressDetail']?.toString() ?? '';
if (provinceCode.isNotEmpty ||
cityCode.isNotEmpty ||
districtCode.isNotEmpty ||
addressDetail.isNotEmpty) {
await StoreLocalCache.instance.saveRegion(
orgId,
provinceCode,
cityCode,
districtCode,
addressDetail,
);
}
}
// ================ 工具方法 ================
/// 在组织树中按节点 ID 深度优先查找节点。
static OrgNode? _findNode(List<OrgNode> nodes, String target) {
for (final n in nodes) {
if (n.id == target) return n;
final found = _findNode(n.children, target);
if (found != null) return found;
}
return null;
}
/// 收集某节点(含自身)的全部后代 ID(深度优先,含 root 本身)。
///
/// 用于组织树筛选:选中某个渠道 / 代理商 / 分组后,命中其下所有层级的门店。
/// 找不到 [rootId] 对应节点时返回空集合。
///
/// [nodes] 为已按 [SessionOrgScope] 收敛的组织树([getOrgTree] 返回值)。
static Set<String> collectDescendantIds(List<OrgNode> nodes, String rootId) {
final root = _findNode(nodes, rootId);
final ids = <String>{};
void walk(List<OrgNode> ns) {
for (final n in ns) {
ids.add(n.id);
walk(n.children);
}
}
if (root != null) walk([root]);
return ids;
}
/// 收集某节点(含自身)的全部后代中「门店(type=3)」的 ID。
///
/// 与 [collectDescendantIds] 的区别:仅收集门店节点,过滤掉渠道/代理/分组等
/// 非门店节点,便于直接作为 `/device/info/list` 的 `storeOrgId` 入参集合使用。
/// 找不到 [rootId] 对应节点时返回空集合。
static Set<String> collectDescendantStoreIds(
List<OrgNode> nodes,
String rootId,
) {
final root = _findNode(nodes, rootId);
final ids = <String>{};
void walk(List<OrgNode> ns) {
for (final n in ns) {
if (n.type == 3) ids.add(n.id);
walk(n.children);
}
}
if (root != null) walk([root]);
return ids;
}
/// 将组织树收敛到「当前登录用户所属组织子树」(含自身与全部后代)。
///
/// - 未启用 [SessionOrgScope](管理员 / orgId 为空):原样返回。
/// - 已启用但树中找不到对应节点:兜底原样返回,避免误清空。
///
/// 这样即便后端 `/sys/org/tree` 返回全量树,非管理员用户也只能看到
/// 自己组织及下属门店的数据。
static List<OrgNode> _scopeTree(List<OrgNode> tree) {
final scope = SessionOrgScope.instance;
if (!scope.enabled) return tree;
final root = _findNode(tree, scope.currentOrgId);
// 兜底:找不到当前组织节点时不过滤(避免误清空导致白屏),
// 但打印告警以便排查「用户 orgId 与组织树节点不匹配」的数据异常。
if (root == null) {
debugPrint(
'[OrgService._scopeTree] 未在组织树中找到当前组织节点 '
'(orgId=${scope.currentOrgId}),放弃客户端收敛',
);
return tree;
}
return [root];
}
/// 收集「当前已收敛组织树」包含的全部节点 ID(用于裁剪扁平/下拉列表与用户列表)。
///
/// 仅在 [SessionOrgScope] 启用时返回非空集合([getOrgTree] 已按范围收敛);
/// 未启用时返回空集合,调用方据此跳过过滤、保持原样。
Future<Set<String>> scopedOrgIdSet() async {
final scope = SessionOrgScope.instance;
if (!scope.enabled) return const {};
final tree = await getOrgTree();
final ids = <String>{};
void collect(List<OrgNode> nodes) {
for (final n in nodes) {
ids.add(n.id);
collect(n.children);
}
}
collect(tree);
return ids;
}
/// 兼容列表响应的三种形态:
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
static List<dynamic> _extractList(dynamic data) {
if (data is List) {
return data;
}
if (data is Map<String, dynamic>) {
final list = data['list'];
if (list is List) {
return list;
}
final page = data['page'];
if (page is Map<String, dynamic> && page['list'] is List) {
return page['list'] as List;
}
}
return const <dynamic>[];
}
}