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

748 lines
28 KiB
Dart
Raw Permalink 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.

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>[];
}
}