diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs index 3c1725e..dd28a19 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs @@ -21,10 +21,13 @@ namespace Yi.Framework.SqlSugarCore { #region Properties + private ISqlSugarClient? _sqlSugarClient; + private readonly object _clientLock = new(); + /// - /// SqlSugar客户端实例 + /// SqlSugar 客户端(延迟按当前租户解析连接串,避免构造时 CurrentTenant 尚未就绪落到 host 库) /// - public ISqlSugarClient SqlSugarClient { get; private set; } + public ISqlSugarClient SqlSugarClient => GetOrCreateClient(); /// /// 延迟服务提供者 @@ -75,22 +78,62 @@ namespace Yi.Framework.SqlSugarCore public SqlSugarDbContextFactory(IAbpLazyServiceProvider lazyServiceProvider) { LazyServiceProvider = lazyServiceProvider; + } - // 异步获取租户配置 - var tenantConfiguration = AsyncHelper.RunSync(async () => await TenantConfigurationWrapper.GetAsync()); - - // 构建数据库连接配置 - var connectionConfig = BuildConnectionConfig(options => + private ISqlSugarClient GetOrCreateClient() + { + // 必须每次按当前租户解析连接串:MultiTenancyMiddleware 查 YiTenant 时会先 Change(null) + // 创建主库客户端;若缓存不切换,后续业务会误连 host。 + var tenantConfiguration = + AsyncHelper.RunSync(async () => await TenantConfigurationWrapper.GetAsync()); + var connectionString = tenantConfiguration.GetCurrentConnectionString(); + var dbType = GetCurrentDbType(tenantConfiguration.GetCurrentConnectionName()); + + if (_sqlSugarClient is not null && + string.Equals( + _sqlSugarClient.CurrentConnectionConfig?.ConnectionString, + connectionString, + StringComparison.Ordinal)) { - options.ConnectionString = tenantConfiguration.GetCurrentConnectionString(); - options.DbType = GetCurrentDbType(tenantConfiguration.GetCurrentConnectionName()); - }); + return _sqlSugarClient; + } - // 创建SqlSugar客户端实例 - SqlSugarClient = new SqlSugarClient(connectionConfig); + lock (_clientLock) + { + if (_sqlSugarClient is not null && + string.Equals( + _sqlSugarClient.CurrentConnectionConfig?.ConnectionString, + connectionString, + StringComparison.Ordinal)) + { + return _sqlSugarClient; + } - // 配置数据库AOP - ConfigureDbAop(SqlSugarClient); + if (_sqlSugarClient is not null) + { + try + { + _sqlSugarClient.Dispose(); + } + catch + { + // ignore dispose race + } + + _sqlSugarClient = null; + } + + var connectionConfig = BuildConnectionConfig(options => + { + options.ConnectionString = connectionString; + options.DbType = dbType; + }); + + var client = new SqlSugarClient(connectionConfig); + ConfigureDbAop(client); + _sqlSugarClient = client; + return _sqlSugarClient; + } } /// diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/AuthSession/CurrentUserBriefDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/AuthSession/CurrentUserBriefDto.cs index 4567f6d..be7e2be 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/AuthSession/CurrentUserBriefDto.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/AuthSession/CurrentUserBriefDto.cs @@ -5,6 +5,9 @@ namespace FoodLabeling.Application.Contracts.Dtos.AuthSession; /// public class CurrentUserBriefDto { + /// + /// 当前登录用户 Id(非租户 Id)。 + /// public Guid Id { get; set; } public string UserName { get; set; } = string.Empty; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/AuthSession/CurrentUserMenuPermissionsOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/AuthSession/CurrentUserMenuPermissionsOutputDto.cs index e73ed13..246ffd2 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/AuthSession/CurrentUserMenuPermissionsOutputDto.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/AuthSession/CurrentUserMenuPermissionsOutputDto.cs @@ -5,6 +5,11 @@ namespace FoodLabeling.Application.Contracts.Dtos.AuthSession; /// public class CurrentUserMenuPermissionsOutputDto { + /// + /// 当前登录用户 Id(JSON: userId)。与 . 一致,非租户 Id。 + /// + public Guid UserId { get; set; } + public CurrentUserBriefDto User { get; set; } = new(); public List RoleCodes { get; set; } = new(); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Common/ITrainingFileScopeInput.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Common/ITrainingFileScopeInput.cs new file mode 100644 index 0000000..3e252a2 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Common/ITrainingFileScopeInput.cs @@ -0,0 +1,25 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Common; + +/// +/// 培训文件创建/编辑入参中的 Company / Region / Location 适用范围字段。 +/// +public interface ITrainingFileScopeInput : ILabelEntityPartnerScopeInput +{ + /// 适用 Region:ALL / SPECIFIED + string? AppliedRegionType { get; } + + /// 适用 Region(fl_group.Id);与 合并;可含 ALL + List? RegionIds { get; } + + /// 相同;可含 ALL + List? GroupIds { get; } + + /// 适用 Location:ALL / SPECIFIED(与 二选一) + string? AvailabilityType { get; } + + /// 适用 Location:ALL / SPECIFIED( 别名) + string? AppliedLocationType { get; } + + /// 适用门店(location.Id);可含 ALL + List? LocationIds { get; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelBatchCreateItemInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelBatchCreateItemInputVo.cs index 00df67d..6a91e82 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelBatchCreateItemInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelBatchCreateItemInputVo.cs @@ -13,6 +13,9 @@ public class LabelBatchCreateItemInputVo public List? PartnerIds { get; set; } + /// 适用 Company(单选) + public List? CompanyIds { get; set; } + public string? AppliedRegionType { get; set; } public List? RegionIds { get; set; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelCreateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelCreateInputVo.cs index 759481e..69d0d12 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelCreateInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelCreateInputVo.cs @@ -2,6 +2,7 @@ namespace FoodLabeling.Application.Contracts.Dtos.Label; public class LabelCreateInputVo { + /// 标签编码(可选;未传或空字符串时由后端自动生成唯一编码) public string? LabelCode { get; set; } public string LabelName { get; set; } = string.Empty; @@ -9,32 +10,38 @@ public class LabelCreateInputVo public string TemplateCode { get; set; } = string.Empty; /// - /// 适用 Company(fl_partner.Id);与 合并,用于解析所属门店 + /// 适用 Company(fl_partner.Id);与 / 合并,单选 /// public string? PartnerId { get; set; } /// - /// 适用 Company 多选(fl_partner.Id) + /// 适用 Company 多选字段(仅支持 1 个;与 相同) /// public List? PartnerIds { get; set; } /// - /// 适用 Region 范围:ALL(全选)/ SPECIFIED(按 )。传了有效 regionIds 时按 SPECIFIED 处理。 + /// 适用 Company(单选,fl_partner.Id);与 / 合并 + /// + public List? CompanyIds { get; set; } + + /// + /// 适用 Region 范围:ALL / SPECIFIED。 + /// 亦可在 / 传哨兵 ["ALL"](即使本字段为 SPECIFIED 亦归档 ALL;POST/PUT 均支持)。 /// public string? AppliedRegionType { get; set; } /// - /// 适用 Region 多选(fl_group.Id);与 合并,落库 fl_label_region + /// 适用 Region 多选(fl_group.Id);与 合并;可含 ALL /// public List? RegionIds { get; set; } /// - /// 适用 Region 多选(与 相同) + /// 适用 Region 多选(与 相同);可含 ALL /// public List? GroupIds { get; set; } /// - /// 适用门店 Id 数组(location.Id,落库 fl_label_location);主字段 + /// 适用门店 Id 数组(location.Id);可含 ALL;落库 fl_label_location /// public List? LocationIds { get; set; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetOutputDto.cs index fc8d69d..8b2d0f2 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetOutputDto.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetOutputDto.cs @@ -17,11 +17,14 @@ public class LabelGetOutputDto public string LocationName { get; set; } = string.Empty; - /// 适用 Company Id(由所属门店反推) + /// 适用 Company Id(单选;优先落库 fl_label.PartnerId public string? PartnerId { get; set; } public List PartnerIds { get; set; } = new(); + /// 相同(兼容字段,单选) + public List CompanyIds { get; set; } = new(); + /// 适用 Region 范围:ALL / SPECIFIED public string AppliedRegionType { get; set; } = "SPECIFIED"; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelUpdateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelUpdateInputVo.cs index c1904fa..f5dab1a 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelUpdateInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelUpdateInputVo.cs @@ -10,17 +10,23 @@ public class LabelUpdateInputVo public List? PartnerIds { get; set; } + /// 适用 Company(单选);与 / 合并 + public List? CompanyIds { get; set; } + /// - /// 适用 Region 范围:ALL / SPECIFIED(见 ) + /// 适用 Region 范围:ALL / SPECIFIED。 + /// 亦可在 / 传哨兵 ["ALL"](与新增一致,PUT 支持)。 /// public string? AppliedRegionType { get; set; } + /// 适用 Region 多选;可含 ALL public List? RegionIds { get; set; } + /// 相同;可含 ALL public List? GroupIds { get; set; } /// - /// 适用门店 Id 数组(location.Id,落库 fl_label_location);主字段,全量覆盖 + /// 适用门店 Id 数组;可含 ALL;落库 fl_label_location,全量覆盖 /// public List? LocationIds { get; set; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerCheckExpiredInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerCheckExpiredInputVo.cs new file mode 100644 index 0000000..a8fdf22 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerCheckExpiredInputVo.cs @@ -0,0 +1,13 @@ +namespace FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer; + +/// +/// 检查告警计时器是否过期入参(至少提供一个标识) +/// +public class LabelAlertTimerCheckExpiredInputVo +{ + public string? TimerId { get; set; } + + public string? BatchId { get; set; } + + public string? PrintTaskId { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerCheckExpiredOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerCheckExpiredOutputDto.cs new file mode 100644 index 0000000..5fff58b --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerCheckExpiredOutputDto.cs @@ -0,0 +1,29 @@ +namespace FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer; + +/// +/// 检查告警计时器是否过期出参 +/// +public class LabelAlertTimerCheckExpiredOutputDto +{ + /// 是否找到计时器记录 + public bool Found { get; set; } + + /// 是否已过期;无记录时为 false(仅展示用,不用于拦截打印) + public bool IsExpired { get; set; } + + public DateTime? ExpiresAt { get; set; } + + /// 剩余秒数(已过期为 0) + public int RemainingSeconds { get; set; } + + /// 状态:expiredrunning + public string? Status { get; set; } + + public string? Title { get; set; } + + public string? Subtitle { get; set; } + + public string? TimerId { get; set; } + + public string? BatchId { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerGetListInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerGetListInputVo.cs new file mode 100644 index 0000000..b216881 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerGetListInputVo.cs @@ -0,0 +1,20 @@ +using Volo.Abp.Application.Dtos; + +namespace FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer; + +/// +/// 告警计时器分页列表入参 +/// +public class LabelAlertTimerGetListInputVo : PagedAndSortedResultRequestDto +{ + /// + /// 当前门店 Id(location.Id,Guid 字符串)。 + /// list 必填;app-list 可空(空则取已选门店缓存)。 + /// + public string? LocationId { get; set; } + + /// + /// 打印日期(yyyy-MM-dd);按 PrintedAt 筛选该日记录。 + /// + public string? DateDay { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerListItemDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerListItemDto.cs new file mode 100644 index 0000000..f5a783a --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerListItemDto.cs @@ -0,0 +1,38 @@ +namespace FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer; + +/// +/// 告警计时器列表项 +/// +public class LabelAlertTimerListItemDto +{ + public string Id { get; set; } = string.Empty; + + public string BatchId { get; set; } = string.Empty; + + public string PrintTaskId { get; set; } = string.Empty; + + public string LabelId { get; set; } = string.Empty; + + public string? LabelCode { get; set; } + + public string Title { get; set; } = string.Empty; + + public string Subtitle { get; set; } = string.Empty; + + /// 总时长(秒),同 DurationSeconds + public int TotalTime { get; set; } + + /// 剩余时长(秒) + public int RemainingTime { get; set; } + + /// 状态:expiredrunning + public string Status { get; set; } = string.Empty; + + public DateTime ExpiresAt { get; set; } + + public DateTime PrintedAt { get; set; } + + public string LocationId { get; set; } = string.Empty; + + public string? ProductName { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryCreateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryCreateInputVo.cs index 3e630ec..b05eb6a 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryCreateInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryCreateInputVo.cs @@ -4,7 +4,8 @@ namespace FoodLabeling.Application.Contracts.Dtos.LabelCategory; public class LabelCategoryCreateInputVo : ILabelEntityPartnerScopeInput { - public string CategoryCode { get; set; } = string.Empty; + /// 分类编码(可选;未传或空字符串时由后端自动生成唯一编码) + public string? CategoryCode { get; set; } public string CategoryName { get; set; } = string.Empty; @@ -37,25 +38,28 @@ public class LabelCategoryCreateInputVo : ILabelEntityPartnerScopeInput public List? CompanyIds { get; set; } /// - /// 门店可用范围:ALL / SPECIFIED + /// 门店可用范围:ALL / SPECIFIED。 + /// regionIds/groupIds/locationIds 可传哨兵 ["ALL"](大小写不敏感); + /// 即使本字段为 SPECIFIED,Id 数组为 ALL 时仍归档为 ALL(POST/PUT 均支持)。 /// public string AvailabilityType { get; set; } = "ALL"; /// - /// 适用 Region(多选),fl_group.Id;与 合并去重 + /// 适用 Region(多选),fl_group.Id;与 合并去重;可含 ALL /// public List? RegionIds { get; set; } /// - /// 适用 Region(多选),与 相同 + /// 适用 Region(多选),与 相同;可含 ALL /// public List? GroupIds { get; set; } /// - /// 适用门店(多选),location.Id;与 Region 合并后写入 fl_label_category_location + /// 适用门店(多选),location.Id;可含 ALL;与 Region 合并后写入 fl_label_category_location /// public List? LocationIds { get; set; } - public int OrderNum { get; set; } + /// 排序;未传或 null 时默认 0 + public int? OrderNum { get; set; } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelMultipleOption/LabelMultipleOptionCreateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelMultipleOption/LabelMultipleOptionCreateInputVo.cs index 7802d0d..3371738 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelMultipleOption/LabelMultipleOptionCreateInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelMultipleOption/LabelMultipleOptionCreateInputVo.cs @@ -4,7 +4,7 @@ namespace FoodLabeling.Application.Contracts.Dtos.LabelMultipleOption; public class LabelMultipleOptionCreateInputVo : ILabelEntityPartnerScopeInput { - /// 多选项编码(可选;未传或空字符串时存空,列表/详情出参为「无」) + /// 多选项编码(可选;未传或空字符串时由后端自动生成唯一编码) public string? OptionCode { get; set; } public string OptionName { get; set; } = string.Empty; @@ -25,25 +25,27 @@ public class LabelMultipleOptionCreateInputVo : ILabelEntityPartnerScopeInput public List? CompanyIds { get; set; } /// - /// 门店可用范围:ALL / SPECIFIED;传了 时自动为 SPECIFIED + /// 门店可用范围:ALL / SPECIFIED;传了 时自动为 SPECIFIED。 + /// Id 数组可传哨兵 ALL(POST/PUT 新增与编辑均支持),归档为 availabilityType=ALL 且不写门店快照。 /// public string AvailabilityType { get; set; } = "ALL"; /// - /// 适用 Region(多选),fl_group.Id;与 合并去重 + /// 适用 Region(多选),fl_group.Id;与 合并去重。可传 ALL。 /// public List? RegionIds { get; set; } /// - /// 适用 Region(多选),与 相同 + /// 适用 Region(多选),与 相同。可传 ALL。 /// public List? GroupIds { get; set; } /// - /// 适用门店(多选),location.Id;与 Region 合并后写入 fl_label_multiple_option_location + /// 适用门店(多选),location.Id;与 Region 合并后写入 fl_label_multiple_option_location。可传 ALL。 /// public List? LocationIds { get; set; } - public int OrderNum { get; set; } + /// 排序;未传或 null 时默认 0 + public int? OrderNum { get; set; } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelType/LabelTypeCreateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelType/LabelTypeCreateInputVo.cs index 131dcdc..42a8f3b 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelType/LabelTypeCreateInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelType/LabelTypeCreateInputVo.cs @@ -4,7 +4,8 @@ namespace FoodLabeling.Application.Contracts.Dtos.LabelType; public class LabelTypeCreateInputVo : ILabelEntityPartnerScopeInput { - public string TypeCode { get; set; } = string.Empty; + /// 类型编码;为空时由后端自动生成唯一编码 + public string? TypeCode { get; set; } public string TypeName { get; set; } = string.Empty; @@ -22,25 +23,28 @@ public class LabelTypeCreateInputVo : ILabelEntityPartnerScopeInput public List? CompanyIds { get; set; } /// - /// 门店可用范围:ALL / SPECIFIED;传了 时自动为 SPECIFIED + /// 门店可用范围:ALL / SPECIFIED。 + /// regionIds/groupIds/locationIds 可传哨兵 ["ALL"](大小写不敏感); + /// 即使本字段为 SPECIFIED,Id 数组为 ALL 时仍归档为 ALL(POST/PUT 均支持)。 /// public string AvailabilityType { get; set; } = "ALL"; /// - /// 适用 Region(多选),fl_group.Id;与 合并去重 + /// 适用 Region(多选),fl_group.Id;与 合并去重;可含 ALL /// public List? RegionIds { get; set; } /// - /// 适用 Region(多选),与 相同 + /// 适用 Region(多选),与 相同;可含 ALL /// public List? GroupIds { get; set; } /// - /// 适用门店(多选),location.Id;与 Region 合并后写入 fl_label_type_location + /// 适用门店(多选),location.Id;可含 ALL;与 Region 合并后写入 fl_label_type_location /// public List? LocationIds { get; set; } - public int OrderNum { get; set; } + /// 排序;未传或 null 时默认 0 + public int? OrderNum { get; set; } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportOnlineInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportOnlineInputVo.cs new file mode 100644 index 0000000..bb89576 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportOnlineInputVo.cs @@ -0,0 +1,12 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Product; + +/// +/// 产品 JSON 在线批量导入请求体 +/// +public class ProductBatchImportOnlineInputVo +{ + /// + /// 待导入行,每元素与单条 POST /api/app/product 一致 + /// + public List Items { get; set; } = new(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportOnlineResultDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportOnlineResultDto.cs new file mode 100644 index 0000000..9019b7a --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportOnlineResultDto.cs @@ -0,0 +1,31 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Product; + +/// +/// 产品 JSON 在线批量导入结果 +/// +public class ProductBatchImportOnlineResultDto +{ + public int SuccessCount { get; set; } + + public int FailCount { get; set; } + + public List Errors { get; set; } = new(); +} + +/// +/// 产品 JSON 在线批量导入单条失败信息 +/// +public class ProductBatchImportOnlineErrorDto +{ + /// + /// 在请求 items 数组中的序号(从 0 开始) + /// + public int Index { get; set; } + + /// + /// 产品名称(productName),便于定位失败行 + /// + public string? ProductName { get; set; } + + public string Message { get; set; } = string.Empty; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs index 5f849e5..aa46c7d 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; +using FoodLabeling.Application.Contracts.Json; namespace FoodLabeling.Application.Contracts.Dtos.Product; @@ -35,18 +37,46 @@ public class ProductCreateInputVo public string? AvailabilityType { get; set; } /// - /// 适用 Company(fl_partner.Id,UI 称 Company);展开该公司下全部门店后与 Region/门店合并写入 fl_location_product + /// 适用 Company:ALL / SPECIFIED(产品 Company 仅支持 SPECIFIED + 单选具体 Guid)。 /// + public string? AppliedPartnerType { get; set; } + + /// + /// 适用 Company(fl_partner.Id,UI 称 Company);展开该公司下全部门店后与 Region/门店合并写入 fl_location_product。 + /// 与 二选一或同值;产品 Company 仅支持单选不支持 ALL。 + /// 兼容前端误传数组:"partnerId":["{guid}"] 与字符串等价。 + /// + [JsonConverter(typeof(StringOrFirstArrayItemJsonConverter))] public string? PartnerId { get; set; } /// - /// 适用 Region(fl_group.Id,UI 称 Region;库字段为 location.GroupName) + /// 适用 Company Id 列表(与 同义,前端常用字段)。仅支持单选。 + /// + public List? PartnerIds { get; set; } + + /// + /// 适用 Company(推荐前端字段)。仅支持单选:数组最多 1 个具体 Guid;不支持 ALL。 + /// 传多个 Guid 或含 ALL 将报错;与 同时传时须一致。 + /// + public List? CompanyIds { get; set; } + + /// 适用 Region:ALL / SPECIFIED(与 独立,用于前端显式声明) + public string? AppliedRegionType { get; set; } + + /// + /// 适用 Region(fl_group.Id,UI 称 Region;库字段为 location.GroupName)。 + /// 与 合并;含 ALL 且无具体门店时,有具体 Company 则展开该公司门店,否则归档全局 ALL。 /// public List? GroupIds { get; set; } /// - /// 适用门店 Id 列表;与 合并后写入 fl_location_product。 - /// 不传则不在本接口写入门店关联。 + /// 适用 Region(与 同义,前端常用字段)。 + /// + public List? RegionIds { get; set; } + + /// + /// 适用门店 Id 列表。有具体 Guid 时以门店为准;含 ALL 且已指定 Company/Region 时表示该范围内全选(SPECIFIED 快照); + /// 无具体 Company/Region 时归档全局 ALL。 /// public List? LocationIds { get; set; } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs index a5da767..2d6b923 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs @@ -29,17 +29,25 @@ public class ProductGetOutputDto /// 适用门店:ALL / SPECIFIED public string AvailabilityType { get; set; } = "SPECIFIED"; - /// 适用 Company Id(fl_partner.Id,由关联门店反推;多公司时取第一个) + /// 适用 Company Id(fl_partner.Id,由关联门店反推;多公司时取第一个;ALL 时为 null) public string? PartnerId { get; set; } - /// 适用 Company Id 列表(去重) + /// 适用 Company Id 列表(去重;历史数据可能多条) public List PartnerIds { get; set; } = new(); + /// + /// 适用 Company(与编辑入参对齐)。单选:0~1 个具体 Guid;不支持 ALL。 + /// + public List CompanyIds { get; set; } = new(); + /// 适用 Region Id(fl_group.Id,由关联门店反推) public List GroupIds { get; set; } = new(); + /// 适用 Region(与 同义) + public List RegionIds { get; set; } = new(); + /// - /// 适用门店 Id 列表(来自 fl_location_product) + /// 适用门店 Id 列表(来自 fl_location_product;覆盖 Region 全集时可折叠为 ["ALL"]) /// public List LocationIds { get; set; } = new(); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs index 77be6d3..435f05d 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs @@ -1,13 +1,13 @@ namespace FoodLabeling.Application.Contracts.Dtos.ProductCategory; +using FoodLabeling.Application.Contracts.Dtos.Common; + /// /// 产品模块:新增类别入参 /// -public class ProductCategoryCreateInputVo +public class ProductCategoryCreateInputVo : ILabelEntityPartnerScopeInput { - /// - /// 类别编码(可选,不传或空字符串表示无编码) - /// + /// 类别编码(可选;未传或空字符串时由后端自动生成唯一编码) public string? CategoryCode { get; set; } public string CategoryName { get; set; } = string.Empty; @@ -30,25 +30,40 @@ public class ProductCategoryCreateInputVo public bool State { get; set; } = true; /// - /// 门店可用范围:ALL / SPECIFIED + /// 适用 Company:ALL / SPECIFIED。 + /// partnerIds/companyIds 可传哨兵 ["ALL"](大小写不敏感)。 + /// + public string? AppliedPartnerType { get; set; } + + /// 适用 Company(fl_partner.Id + public List? PartnerIds { get; set; } + + /// 相同(兼容字段) + public List? CompanyIds { get; set; } + + /// + /// 门店可用范围:ALL / SPECIFIED。 + /// regionIds/groupIds/locationIds 可传哨兵 ["ALL"](大小写不敏感); + /// 即使本字段为 SPECIFIED,Id 数组为 ALL 时仍归档为 ALL(POST/PUT 均支持)。 /// public string AvailabilityType { get; set; } = "ALL"; /// - /// 适用 Region(**多选**),fl_group.Id 数组;与 等价,推荐本字段。 + /// 适用 Region(**多选**),fl_group.Id;可含 ALL;与 合并去重。 /// public List? RegionIds { get; set; } /// - /// 适用 Region(多选),与 相同;传任一会合并去重。 + /// 适用 Region(多选),与 相同;可含 ALL。 /// public List? GroupIds { get; set; } /// - /// 适用门店(**多选**),location.Id 数组;与 Region 合并后写入 fl_product_category_location。 + /// 适用门店(**多选**),location.Id;可含 ALL;与 Region 合并后写入 fl_product_category_location。 /// public List? LocationIds { get; set; } - public int OrderNum { get; set; } = 0; + /// 排序;未传或 null 时默认 0 + public int? OrderNum { get; set; } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs index ebfc336..7be3554 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs @@ -21,6 +21,16 @@ public class ProductCategoryGetListOutputDto public string AvailabilityType { get; set; } = "ALL"; + public string AppliedPartnerType { get; set; } = "ALL"; + + /// 适用 Company 展示 + public string Company { get; set; } = string.Empty; + + public List PartnerIds { get; set; } = new(); + + /// 相同 + public List CompanyIds { get; set; } = new(); + public int OrderNum { get; set; } public DateTime LastEdited { get; set; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs index 09e0891..7727aa2 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs @@ -22,6 +22,16 @@ public class ProductCategoryGetOutputDto public string AvailabilityType { get; set; } = "ALL"; + public string AppliedPartnerType { get; set; } = "ALL"; + + /// 列表/详情 Company 展示 + public string Company { get; set; } = string.Empty; + + public List PartnerIds { get; set; } = new(); + + /// 相同 + public List CompanyIds { get; set; } = new(); + /// 适用 Region Id 列表(多选,fl_group.Id;由绑定门店反推) public List RegionIds { get; set; } = new(); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductLocation/ProductLocationGetListInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductLocation/ProductLocationGetListInputVo.cs index f0864f1..7e5ff02 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductLocation/ProductLocationGetListInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductLocation/ProductLocationGetListInputVo.cs @@ -9,6 +9,12 @@ namespace FoodLabeling.Application.Contracts.Dtos.ProductLocation; public class ProductLocationGetListInputVo : PagedAndSortedResultRequestDto { /// + /// Company Id(fl_partner.Id);未传 时按该公司下门店展开, + /// 并包含 AvailabilityType=ALL 的产品。 + /// + public string? PartnerId { get; set; } + + /// /// 门店Id(location.Id,string 表示) /// public string? LocationId { get; set; } @@ -18,4 +24,3 @@ public class ProductLocationGetListInputVo : PagedAndSortedResultRequestDto /// public string? ProductId { get; set; } } - diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleCreateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleCreateInputVo.cs index 41bf2f0..6bb0c2e 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleCreateInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleCreateInputVo.cs @@ -24,11 +24,16 @@ public class RbacRoleCreateInputVo public int? OrderNum { get; set; } /// - /// 绑定菜单 Id;与 accessPermissions 同时传时以本字段为准 + /// 绑定菜单 Id;与 menuPermissionKeys / accessPermissions 同时传时以本字段为准 /// public List? MenuIds { get; set; } /// + /// 绑定菜单 Id(字符串 Guid 列表);与 等价, 优先 + /// + public List? MenuPermissionKeys { get; set; } + + /// /// 按 PermissionCode 绑定菜单:JSON 数组字符串(如 ["manage_labels"])、英文逗号分隔;传空字符串表示清空绑定;不传则不修改已有绑定(仅编辑时) /// public string? AccessPermissions { get; set; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleGetListOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleGetListOutputDto.cs index d8d2210..5d709ed 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleGetListOutputDto.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleGetListOutputDto.cs @@ -28,5 +28,10 @@ public class RbacRoleGetListOutputDto /// 角色访问权限码列表(与库字段 AccessPermissionCodes 对应) /// public List AccessPermissionCodes { get; set; } = new(); + + /// + /// 已绑定菜单 Id 列表(字符串 Guid,与前端 menuPermissionKeys 字段一致) + /// + public List MenuPermissionKeys { get; set; } = new(); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportOnlineInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportOnlineInputVo.cs new file mode 100644 index 0000000..02a9d49 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportOnlineInputVo.cs @@ -0,0 +1,12 @@ +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember; + +/// +/// 成员 JSON 在线批量导入请求体 +/// +public class TeamMemberBatchImportOnlineInputVo +{ + /// + /// 待导入行,每元素与单条 POST /api/app/team-member 一致 + /// + public List Items { get; set; } = new(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportOnlineResultDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportOnlineResultDto.cs new file mode 100644 index 0000000..fec2e63 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportOnlineResultDto.cs @@ -0,0 +1,31 @@ +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember; + +/// +/// 成员 JSON 在线批量导入结果 +/// +public class TeamMemberBatchImportOnlineResultDto +{ + public int SuccessCount { get; set; } + + public int FailCount { get; set; } + + public List Errors { get; set; } = new(); +} + +/// +/// 成员 JSON 在线批量导入单条失败信息 +/// +public class TeamMemberBatchImportOnlineErrorDto +{ + /// + /// 在请求 items 数组中的序号(从 0 开始) + /// + public int Index { get; set; } + + /// + /// 登录账号(userName),便于定位失败行 + /// + public string? UserName { get; set; } + + public string Message { get; set; } = string.Empty; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs index 646bc84..398e174 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs @@ -28,20 +28,28 @@ public class TeamMemberCreateInputVo public List? PartnerIds { get; set; } /// - /// 适用 Region 多选(fl_group.Id);Company Admin 仅传 Company 时可省略,后端自动绑定该公司下全部 Region 与门店 + /// 适用 Region 多选(fl_group.Id);可含 ALL 哨兵(大小写不敏感,与具体 Guid 同传时以 ALL 为准)。 + /// Company Admin 仅传 Company 时可省略;locationIds 为空且含 ALL 时展开该公司全部 Region 下门店。 /// public List? RegionIds { get; set; } /// - /// 适用 Region 多选(与 相同) + /// 适用 Region 多选(与 相同);可含 ALL。 /// public List? GroupIds { get; set; } /// - /// 适用门店多选(location.Id);Company Admin 仅传 Company 时可省略,与 Region 合并后写入 userlocation + /// 适用门店多选(location.Id);可含 ALL 哨兵。 + /// 有具体 Region 时展开该 Region 下门店;仅有 Company 时展开该公司全部门店。 + /// Company Admin 仅传 Company 时可省略。 /// public List? LocationIds { get; set; } + /// + /// 适用门店多选别名(与 合并解析);可含 ALL 或门店 Guid。 + /// + public List? Locations { get; set; } + public bool State { get; set; } = true; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetOutputDto.cs index bf245f9..9df7631 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetOutputDto.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetOutputDto.cs @@ -19,12 +19,13 @@ public class TeamMemberGetOutputDto /// 适用 Company Id(多选,由绑定门店反推) public List PartnerIds { get; set; } = new(); - /// 适用 Region Id(多选,fl_group.Id + /// 适用 Region Id(多选,fl_group.Id;覆盖该公司全部 Region 时回显 ["ALL"] public List RegionIds { get; set; } = new(); /// 相同 public List GroupIds { get; set; } = new(); + /// 绑定门店 Id;覆盖该公司全部门店时回显 ["ALL"](库中仍存展开 Guid) public List LocationIds { get; set; } = new(); public List AssignedLocations { get; set; } = new(); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs index 163b529..df91908 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs @@ -28,20 +28,26 @@ public class TeamMemberUpdateInputVo public List? PartnerIds { get; set; } /// - /// 适用 Region 多选(fl_group.Id);Company Admin 仅传 Company 时可省略 + /// 适用 Region 多选(fl_group.Id);可含 ALL 哨兵(大小写不敏感,与具体 Guid 同传时以 ALL 为准)。 /// public List? RegionIds { get; set; } /// - /// 适用 Region 多选(与 相同) + /// 适用 Region 多选(与 相同);可含 ALL。 /// public List? GroupIds { get; set; } /// - /// 适用门店多选(location.Id);Company Admin 仅传 Company 时可省略 + /// 适用门店多选(location.Id);可含 ALL 哨兵。 + /// 有具体 Region 时表示该 Region 下全部门店;仅有 Company 时按公司全部门店落库。 /// public List? LocationIds { get; set; } + /// + /// 适用门店多选别名(与 合并解析);可含 ALL 或门店 Guid。 + /// + public List? Locations { get; set; } + public bool State { get; set; } = true; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryCreateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryCreateInputVo.cs new file mode 100644 index 0000000..b46a65e --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryCreateInputVo.cs @@ -0,0 +1,11 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingCategoryCreateInputVo +{ + public string CategoryName { get; set; } = string.Empty; + + /// 空=一级分类;有值=二级分类 + public string? ParentId { get; set; } + + public int OrderNum { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryGetOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryGetOutputDto.cs new file mode 100644 index 0000000..45cb875 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryGetOutputDto.cs @@ -0,0 +1,16 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingCategoryGetOutputDto +{ + public string Id { get; set; } = string.Empty; + + public string CategoryName { get; set; } = string.Empty; + + public string? ParentId { get; set; } + + public int OrderNum { get; set; } + + public DateTime CreationTime { get; set; } + + public DateTime? LastModificationTime { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryTreeInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryTreeInputVo.cs new file mode 100644 index 0000000..ca4aa55 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryTreeInputVo.cs @@ -0,0 +1,13 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingCategoryTreeInputVo +{ + /// 关键字(匹配分类名或文件名) + public string? Keyword { get; set; } + + /// 按门店筛选可见文件;不传则不过滤文件权限 + public string? LocationId { get; set; } + + /// 是否包含文件列表,默认 true + public bool IncludeFiles { get; set; } = true; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryTreeNodeDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryTreeNodeDto.cs new file mode 100644 index 0000000..ffeb807 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryTreeNodeDto.cs @@ -0,0 +1,16 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingCategoryTreeNodeDto +{ + public string Id { get; set; } = string.Empty; + + public string CategoryName { get; set; } = string.Empty; + + public string? ParentId { get; set; } + + public int OrderNum { get; set; } + + public List Children { get; set; } = new(); + + public List Files { get; set; } = new(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryUpdateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryUpdateInputVo.cs new file mode 100644 index 0000000..9f7c6c4 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryUpdateInputVo.cs @@ -0,0 +1,8 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingCategoryUpdateInputVo +{ + public string CategoryName { get; set; } = string.Empty; + + public int OrderNum { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileDto.cs new file mode 100644 index 0000000..1f0306c --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileDto.cs @@ -0,0 +1,50 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingFileDto +{ + public string Id { get; set; } = string.Empty; + + public string CategoryId { get; set; } = string.Empty; + + public string FileName { get; set; } = string.Empty; + + public string FileUrl { get; set; } = string.Empty; + + /// image / doc / other + public string FileType { get; set; } = "other"; + + public long FileSize { get; set; } + + public int OrderNum { get; set; } + + public string AppliedPartnerType { get; set; } = "ALL"; + + /// Company 展示(ALL 时为 All Companies) + public string Company { get; set; } = string.Empty; + + public List PartnerIds { get; set; } = new(); + + /// 相同 + public List CompanyIds { get; set; } = new(); + + public string AppliedRegionType { get; set; } = "ALL"; + + /// Region 展示 + public string Region { get; set; } = string.Empty; + + public List RegionIds { get; set; } = new(); + + /// 相同 + public List GroupIds { get; set; } = new(); + + public string AvailabilityType { get; set; } = "ALL"; + + /// Location 展示 + public string Location { get; set; } = string.Empty; + + public List LocationIds { get; set; } = new(); + + public DateTime CreationTime { get; set; } + + public DateTime? LastModificationTime { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileScopeInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileScopeInputVo.cs new file mode 100644 index 0000000..bfc1e58 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileScopeInputVo.cs @@ -0,0 +1,27 @@ +using FoodLabeling.Application.Contracts.Dtos.Common; + +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingFileScopeInputVo : ITrainingFileScopeInput +{ + public string? AppliedPartnerType { get; set; } + + public List? PartnerIds { get; set; } + + public List? CompanyIds { get; set; } + + /// 适用 Region:ALL / SPECIFIED + public string? AppliedRegionType { get; set; } + + public List? RegionIds { get; set; } + + public List? GroupIds { get; set; } + + /// 适用 Location:ALL / SPECIFIED + public string? AvailabilityType { get; set; } + + /// 适用 Location:ALL / SPECIFIED( 别名) + public string? AppliedLocationType { get; set; } + + public List? LocationIds { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileScopeOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileScopeOutputDto.cs new file mode 100644 index 0000000..77b51ce --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileScopeOutputDto.cs @@ -0,0 +1,26 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingFileScopeOutputDto +{ + public string AppliedPartnerType { get; set; } = "ALL"; + + public string Company { get; set; } = string.Empty; + + public List PartnerIds { get; set; } = new(); + + public List CompanyIds { get; set; } = new(); + + public string AppliedRegionType { get; set; } = "ALL"; + + public string Region { get; set; } = string.Empty; + + public List RegionIds { get; set; } = new(); + + public List GroupIds { get; set; } = new(); + + public string AvailabilityType { get; set; } = "ALL"; + + public string Location { get; set; } = string.Empty; + + public List LocationIds { get; set; } = new(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileSortInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileSortInputVo.cs new file mode 100644 index 0000000..b5286db --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileSortInputVo.cs @@ -0,0 +1,6 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingFileSortInputVo +{ + public List Items { get; set; } = new(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileSortItemVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileSortItemVo.cs new file mode 100644 index 0000000..81ec9f4 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileSortItemVo.cs @@ -0,0 +1,8 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingFileSortItemVo +{ + public string Id { get; set; } = string.Empty; + + public int OrderNum { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUpdateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUpdateInputVo.cs new file mode 100644 index 0000000..9cc01c7 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUpdateInputVo.cs @@ -0,0 +1,37 @@ +using FoodLabeling.Application.Contracts.Dtos.Common; + +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingFileUpdateInputVo : ITrainingFileScopeInput +{ + public string FileName { get; set; } = string.Empty; + + public int OrderNum { get; set; } + + /// 适用 Company:ALL / SPECIFIED + public string? AppliedPartnerType { get; set; } + + /// 适用 Company(fl_partner.Id);可含 ALL + public List? PartnerIds { get; set; } + + /// 相同;可含 ALL + public List? CompanyIds { get; set; } + + /// 适用 Region:ALL / SPECIFIED + public string? AppliedRegionType { get; set; } + + /// 适用 Region(fl_group.Id);与 合并;可含 ALL + public List? RegionIds { get; set; } + + /// 相同;可含 ALL + public List? GroupIds { get; set; } + + /// 适用 Location:ALL / SPECIFIED + public string? AvailabilityType { get; set; } + + /// 适用 Location:ALL / SPECIFIED( 别名) + public string? AppliedLocationType { get; set; } + + /// 适用门店(location.Id);可含 ALL + public List? LocationIds { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUploadInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUploadInputVo.cs new file mode 100644 index 0000000..f92b59a --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUploadInputVo.cs @@ -0,0 +1,53 @@ +using FoodLabeling.Application.Contracts.Dtos.Common; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingFileUploadInputVo : ITrainingFileScopeInput +{ + [FromForm(Name = "file")] + public IFormFile File { get; set; } = default!; + + [FromForm(Name = "categoryId")] + public string CategoryId { get; set; } = string.Empty; + + [FromForm(Name = "orderNum")] + public int OrderNum { get; set; } + + /// 适用 Company:ALL / SPECIFIED + [FromForm(Name = "appliedPartnerType")] + public string? AppliedPartnerType { get; set; } + + /// 适用 Company(fl_partner.Id);可含 ALL + [FromForm(Name = "partnerIds")] + public List? PartnerIds { get; set; } + + /// 相同;可含 ALL + [FromForm(Name = "companyIds")] + public List? CompanyIds { get; set; } + + /// 适用 Region:ALL / SPECIFIED + [FromForm(Name = "appliedRegionType")] + public string? AppliedRegionType { get; set; } + + /// 适用 Region(fl_group.Id);与 合并;可含 ALL + [FromForm(Name = "regionIds")] + public List? RegionIds { get; set; } + + /// 相同;可含 ALL + [FromForm(Name = "groupIds")] + public List? GroupIds { get; set; } + + /// 适用 Location:ALL / SPECIFIED + [FromForm(Name = "availabilityType")] + public string? AvailabilityType { get; set; } + + /// 适用 Location:ALL / SPECIFIED( 别名) + [FromForm(Name = "appliedLocationType")] + public string? AppliedLocationType { get; set; } + + /// 适用门店(location.Id);可含 ALL + [FromForm(Name = "locationIds")] + public List? LocationIds { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUploadOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUploadOutputDto.cs new file mode 100644 index 0000000..893443d --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUploadOutputDto.cs @@ -0,0 +1,14 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class TrainingFileUploadOutputDto +{ + public string Id { get; set; } = string.Empty; + + public string FileName { get; set; } = string.Empty; + + public string FileUrl { get; set; } = string.Empty; + + public string FileType { get; set; } = "other"; + + public long FileSize { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/UsAppTrainingTreeInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/UsAppTrainingTreeInputVo.cs new file mode 100644 index 0000000..4d0fc19 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/UsAppTrainingTreeInputVo.cs @@ -0,0 +1,8 @@ +namespace FoodLabeling.Application.Contracts.Dtos.Training; + +public class UsAppTrainingTreeInputVo +{ + public string LocationId { get; set; } = string.Empty; + + public string? Keyword { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IAuthSessionAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IAuthSessionAppService.cs index 601be57..2df43a9 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IAuthSessionAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IAuthSessionAppService.cs @@ -12,8 +12,11 @@ public interface IAuthSessionAppService : IApplicationService /// 获取当前登录用户的角色编码、权限码与可见菜单树 /// /// - /// 与框架 UserManager.GetInfoAsync 一致;用户名为 admin 时返回全部未删除菜单(与 AccountService.GetVue3Router 行为对齐)。 - /// 返回体额外包含:lastUpdated(系统编辑全局时间戳,与任意写接口成功联动;无戳时回退用户 LastModificationTime)、role(角色展示名,多角色英文逗号拼接)、fullName(姓名优先,其次昵称、用户名)。 + /// 非 SaaS(EnabledSaasMultiTenancy=false)且用户名为 admin 时返回全部未删除菜单(与 AccountService.GetVue3Router 行为对齐)。 + /// SaaS 开启时:平台主库登录(JWT / __tenant 无业务租户 Id,含全 0)即使用户名为 admin 也仅返回平台菜单 + /// (PermissionCodemenu.platform 开头或 Router/platform 开头,含祖先节点以成树); + /// 公司业务租户登录时即使用户名为 admin 也按 RoleMenu 关联查询,与 UpdateCompanyMenus 同步的管理员菜单权限一致。 + /// 返回体额外包含:userId(当前登录用户 Id,与 user.id 一致,非租户 Id)、lastUpdated(系统编辑全局时间戳,与任意写接口成功联动;无戳时回退用户 LastModificationTime)、role(角色展示名,多角色英文逗号拼接)、fullName(姓名优先,其次昵称、用户名)。 /// 角色名通过 Role 表直查(RoleDbEntity),避免走仓储 IDataPermission。 /// /// 用户简要信息、权限码与菜单树 diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAlertTimerAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAlertTimerAppService.cs new file mode 100644 index 0000000..31d3243 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAlertTimerAppService.cs @@ -0,0 +1,22 @@ +using FoodLabeling.Application.Contracts.Dtos.Common; +using FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer; +using Volo.Abp.Application.Services; + +namespace FoodLabeling.Application.Contracts.IServices; + +/// +/// 标签告警计时器(App) +/// +public interface ILabelAlertTimerAppService : IApplicationService +{ + Task> GetListAsync(LabelAlertTimerGetListInputVo input); + + /// + /// App:当前账号当前门店告警列表(含倒计时;locationId 可省略,走已选门店缓存) + /// + Task> GetAppListAsync(LabelAlertTimerGetListInputVo input); + + Task DeleteAsync(string id); + + Task CheckExpiredAsync(LabelAlertTimerCheckExpiredInputVo input); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAppService.cs index 7506e37..5abc6f2 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAppService.cs @@ -21,7 +21,8 @@ public interface ILabelAppService : IApplicationService Task GetAsync(string id); /// - /// 新增标签。Body 支持 appliedRegionType(ALL/SPECIFIED)、regionIds / groupIds(落库 fl_label_region)、locationIds(主字段,落库 fl_label_location);locationIdlocationIds 合并;labelTypeId 可选。 + /// 新增标签。Body 支持 appliedRegionType(ALL/SPECIFIED)、 + /// regionIds / groupIds / locationIds(可含哨兵 ALL,归档为 AppliedRegionType=ALL)。 /// Task CreateAsync(LabelCreateInputVo input); @@ -31,7 +32,7 @@ public interface ILabelAppService : IApplicationService Task BatchCreateAsync(LabelBatchCreateInputVo input); /// - /// 编辑标签(id=LabelCode)。适用 Region / 门店字段同 。 + /// 编辑标签(id=LabelCode)。范围传参与 一致,支持 Id 数组传 ALL。 /// Task UpdateAsync(string id, LabelUpdateInputVo input); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelCategoryAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelCategoryAppService.cs index 32464df..fb18670 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelCategoryAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelCategoryAppService.cs @@ -9,8 +9,10 @@ public interface ILabelCategoryAppService Task GetAsync(string id); + /// 新增标签分类;regionIds/groupIds/locationIds 支持哨兵 ALL Task CreateAsync(LabelCategoryCreateInputVo input); + /// 编辑标签分类;范围传参与新增一致,支持 ALL Task UpdateAsync(string id, LabelCategoryUpdateInputVo input); Task DeleteAsync(string id); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelMultipleOptionAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelMultipleOptionAppService.cs index 38919ab..36e6f99 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelMultipleOptionAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelMultipleOptionAppService.cs @@ -9,8 +9,14 @@ public interface ILabelMultipleOptionAppService Task GetAsync(string id); + /// + /// 新增多选项;regionIdsgroupIdslocationIds 可传 ALL 哨兵(POST)。 + /// Task CreateAsync(LabelMultipleOptionCreateInputVo input); + /// + /// 编辑多选项;适用范围与新增相同,regionIdsgroupIdslocationIds 可传 ALL(PUT)。 + /// Task UpdateAsync(string id, LabelMultipleOptionUpdateInputVo input); Task DeleteAsync(string id); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTemplateAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTemplateAppService.cs index 63bfc57..1a855d1 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTemplateAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTemplateAppService.cs @@ -25,12 +25,14 @@ public interface ILabelTemplateAppService : IApplicationService /// /// 新增标签模板;body 支持 Company/Region/Location 三维范围(各维度 ALL/SPECIFIED + Id 数组)。 + /// regionIdsgroupIdslocationIdsappliedLocationIds 可传 ALL 哨兵(POST)。 /// Task CreateAsync(LabelTemplateCreateInputVo input); /// /// 编辑标签模板(版本号 +1,重建 elements);适用范围多选规则同新增。 /// body 支持 printOrientationvertical / horizontal,横打不交换 Width/Height)。 + /// regionIdsgroupIdslocationIdsappliedLocationIds 可传 ALL 哨兵(PUT)。 /// Task UpdateAsync(string id, LabelTemplateUpdateInputVo input); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTypeAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTypeAppService.cs index aa4d03a..41d04aa 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTypeAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTypeAppService.cs @@ -9,8 +9,10 @@ public interface ILabelTypeAppService Task GetAsync(string id); + /// 新增标签类型;regionIds/groupIds/locationIds 支持哨兵 ALL Task CreateAsync(LabelTypeCreateInputVo input); + /// 编辑标签类型;范围传参与新增一致,支持 ALL Task UpdateAsync(string id, LabelTypeUpdateInputVo input); Task DeleteAsync(string id); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs index e40d991..6aee5eb 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs @@ -32,8 +32,10 @@ public interface IProductAppService : IApplicationService /// /// /// 可选;为空时后端生成唯一编码(如 PRD_ + Guid)。 - /// 若传 (Company)、(Region) - /// 和/或 ,合并后写入 fl_location_product。 + /// Company 传 (推荐,仅单选具体 Guid,不支持 ALL) + /// 或 ; + /// 再与 / 合并写入 fl_location_product。 + /// Region/Location 数组含 ["ALL"] 时,即使 AvailabilityType 为 SPECIFIED 也归档为 ALL,清空 fl_location_product 快照。 /// Task CreateAsync(ProductCreateInputVo input); @@ -41,8 +43,8 @@ public interface IProductAppService : IApplicationService /// 编辑产品 /// /// - /// 当请求体包含 - /// 和/或 时,合并后整表替换门店关联;均不传则不改。 + /// 当请求体包含 companyIds/partnerIdgroupIds 和/或 locationIds 时,合并后整表替换门店关联;均不传则不改。 + /// Company 仅支持单选companyIds 最多 1 个具体 Guid,不支持 ALL)。Region/Location ALL 哨兵规则同 。 /// Task UpdateAsync(Guid id, ProductUpdateInputVo input); @@ -74,5 +76,36 @@ public interface IProductAppService : IApplicationService /// 批量编辑产品(JSON 一次提交多行,与单条 PUT 字段一致) /// Task UpdateProductsBulkAsync(ProductBulkUpdateInputVo input); + + /// + /// JSON 在线批量导入产品(逐行调用 ,部分成功) + /// + /// + /// 请求体为 JSON,每行字段与单条新增 一致。 + /// + /// 示例请求: + /// ```json + /// { + /// "items": [ + /// { + /// "productName": "Tuna & Bacon Sub", + /// "categoryId": "CATEGORY_ID", + /// "productCode": "40001", + /// "state": true, + /// "locationIds": ["LOCATION_GUID"] + /// } + /// ] + /// } + /// ``` + /// + /// 参数说明: + /// - items: 待导入行数组;index 从 0 起;单次最多 MaxImportRows 条(默认 5000) + /// + /// 批量导入请求体 + /// 成功数、失败数及失败明细(indexproductNamemessage + /// 全部或部分行处理完成,见返回体中的计数与 errors + /// 整单校验失败(如 items 为空、超过单次条数上限) + /// 服务器错误 + Task BatchImportOnlineAsync(ProductBatchImportOnlineInputVo input); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs index 69e6316..752a87b 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs @@ -17,12 +17,12 @@ public interface IProductCategoryAppService : IApplicationService Task GetAsync(string id); /// - /// 新增类别;categoryCode 可选;body 传 regionIds(Region 多选)与 locationIds(门店多选)绑定适用范围。 + /// 新增类别;支持 companyIds/partnerIds 与 Region/Location 哨兵 ALL(POST)。 /// Task CreateAsync(ProductCategoryCreateInputVo input); /// - /// 编辑类别;categoryCode 可选;regionIds/locationIds 多选数组规则同新增;传空数组 [] 可清空对应范围。 + /// 编辑类别;范围传参与新增一致,companyIds 全选回显/保存支持 ["ALL"](PUT)。 /// Task UpdateAsync(string id, ProductCategoryUpdateInputVo input); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs index 1c8c36d..63564d8 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs @@ -14,8 +14,14 @@ public interface ITeamMemberAppService Task GetAsync(Guid id); + /// + /// 新增成员(POST /api/app/team-member)。locationIds / regionIds / groupIds / locations 可传 ALL 哨兵。 + /// Task CreateAsync(TeamMemberCreateInputVo input); + /// + /// 更新成员(PUT /api/app/team-member/{id})。范围传参规则与 相同。 + /// Task UpdateAsync(Guid id, TeamMemberUpdateInputVo input); Task DeleteAsync(Guid id); @@ -39,4 +45,37 @@ public interface ITeamMemberAppService /// 批量编辑成员(JSON 一次提交多行) /// Task UpdateTeamMembersBulkAsync(TeamMemberBulkUpdateInputVo input); + + /// + /// JSON 在线批量导入成员(逐行调用 ,部分成功) + /// + /// + /// 请求体为 JSON,每行字段与单条新增 一致; + /// password 为空时使用配置 TeamMemberImportDefaultPassword。 + /// + /// 示例请求: + /// ```json + /// { + /// "items": [ + /// { + /// "fullName": "John Doe", + /// "userName": "john@example.com", + /// "email": "john@example.com", + /// "roleId": "ROLE_GUID", + /// "locationIds": ["LOCATION_GUID"], + /// "state": true + /// } + /// ] + /// } + /// ``` + /// + /// 参数说明: + /// - items: 待导入行数组;index 从 0 起;单次最多 MaxImportRows 条(默认 5000) + /// + /// 批量导入请求体 + /// 成功数、失败数及失败明细(indexuserNamemessage + /// 全部或部分行处理完成,见返回体中的计数与 errors + /// 整单校验失败(如 items 为空、超过单次条数上限) + /// 服务器错误 + Task BatchImportOnlineAsync(TeamMemberBatchImportOnlineInputVo input); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITrainingAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITrainingAppService.cs new file mode 100644 index 0000000..f9ec1f1 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITrainingAppService.cs @@ -0,0 +1,27 @@ +using FoodLabeling.Application.Contracts.Dtos.Training; +using Volo.Abp.Application.Services; + +namespace FoodLabeling.Application.Contracts.IServices; + +public interface ITrainingAppService : IApplicationService +{ + Task> GetCategoryTreeAsync(TrainingCategoryTreeInputVo input); + + Task CreateCategoryAsync(TrainingCategoryCreateInputVo input); + + Task UpdateCategoryAsync(string id, TrainingCategoryUpdateInputVo input); + + Task DeleteCategoryAsync(string id); + + Task UploadFileAsync(TrainingFileUploadInputVo input); + + Task UpdateFileAsync(string id, TrainingFileUpdateInputVo input); + + Task DeleteFileAsync(string id); + + Task SortFilesAsync(TrainingFileSortInputVo input); + + Task GetFileScopeAsync(string id); + + Task SetFileScopeAsync(string id, TrainingFileScopeInputVo input); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppTrainingAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppTrainingAppService.cs new file mode 100644 index 0000000..cde8409 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppTrainingAppService.cs @@ -0,0 +1,12 @@ +using FoodLabeling.Application.Contracts.Dtos.Training; +using Volo.Abp.Application.Services; + +namespace FoodLabeling.Application.Contracts.IServices; + +/// +/// App 培训 / 资料中心 +/// +public interface IUsAppTrainingAppService : IApplicationService +{ + Task> GetTreeAsync(UsAppTrainingTreeInputVo input); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Json/StringOrFirstArrayItemJsonConverter.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Json/StringOrFirstArrayItemJsonConverter.cs new file mode 100644 index 0000000..fce83cd --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Json/StringOrFirstArrayItemJsonConverter.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FoodLabeling.Application.Contracts.Json; + +/// +/// 反序列化时兼容 JSON 字符串或字符串数组(取首个非空元素),用于前端误把单选字段传成数组的场景(如 partnerId)。 +/// +public sealed class StringOrFirstArrayItemJsonConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + case JsonTokenType.String: + return reader.GetString(); + case JsonTokenType.StartArray: + { + string? first = null; + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + + if (reader.TokenType == JsonTokenType.Null) + { + continue; + } + + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("partnerId 数组元素必须是字符串"); + } + + var item = reader.GetString()?.Trim(); + if (first is null && !string.IsNullOrWhiteSpace(item)) + { + first = item; + } + } + + return first; + } + default: + throw new JsonException($"无法将 JSON token {reader.TokenType} 转为字符串(partnerId)"); + } + } + + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value); + } + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/UsAppJwtClaims.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/UsAppJwtClaims.cs index 2727138..faa7318 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/UsAppJwtClaims.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/UsAppJwtClaims.cs @@ -10,4 +10,7 @@ public static class UsAppJwtClaims /// 美国版移动端 App public const string ClientKindUsApp = "us-app"; + + /// 泰额版 App(us-app-auth 转发 th-app-auth 签发) + public const string ClientKindThApp = "th_app"; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj index 9caa24b..0b777f9 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj @@ -4,10 +4,35 @@ + + + FoodLabeling.TenantMigrations.fl_entity_applied_region_type.sql + + + FoodLabeling.TenantMigrations.fl_label_partner_id.sql + + + FoodLabeling.TenantMigrations.fl_product_category_partner_scope.sql + + + FoodLabeling.TenantMigrations.fl_userlocation.sql + + + FoodLabeling.TenantMigrations.fl_team_member_scope.sql + + + FoodLabeling.TenantMigrations.fl_training.sql + + + FoodLabeling.TenantMigrations.fl_label_alert_timer.sql + + + + diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs index 46b1dee..63aebad 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs @@ -23,22 +23,36 @@ public static class AllScopeBindingHelper return selected.Count == 0; } - if (selected.Count != universe.Count) + var universeSet = new HashSet( + universe.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()), + StringComparer.OrdinalIgnoreCase); + var selectedSet = new HashSet( + selected.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()), + StringComparer.OrdinalIgnoreCase); + + if (selectedSet.Count != universeSet.Count) { return false; } - var set = new HashSet(universe, StringComparer.Ordinal); - return selected.All(id => set.Contains(id)); + return selectedSet.SetEquals(universeSet); } - /// 解析维度类型:显式 ALL、空数组 ALL、或 Id 列表全选时视为 ALL。 + /// + /// 解析维度类型:显式 ALL、空数组 ALL、或 Id 列表全选时视为 ALL。 + /// 入参显式 时保留 SPECIFIED 并落库全部 Id,不因「当前上下文全选」折叠为 ALL。 + /// public static string ResolveDimensionType( string? declaredType, IReadOnlyList ids, bool hasArrayInPayload, bool isFullSelection) { + if (IsDeclaredSpecified(declaredType)) + { + return ScopeSpecified; + } + if (isFullSelection || (ids.Count == 0 && IsDeclaredAll(declaredType))) { return ScopeAll; @@ -61,6 +75,9 @@ public static class AllScopeBindingHelper public static bool IsDeclaredAll(string? type) => string.Equals((type ?? ScopeAll).Trim(), ScopeAll, StringComparison.OrdinalIgnoreCase); + public static bool IsDeclaredSpecified(string? type) => + string.Equals((type ?? string.Empty).Trim(), ScopeSpecified, StringComparison.OrdinalIgnoreCase); + /// 全部 Company(fl_partner.Id)。 public static async Task> ResolveAllPartnerIdsAsync(ISqlSugarClient db) { @@ -87,20 +104,51 @@ public static class AllScopeBindingHelper return LocationScopeBindingHelper.NormalizeIds(rows); } - /// 全部门店;可按 Company / Region 限定。 + /// + /// 全部门店;可按 Company / Region 限定。 + /// 同时传 Company 与 Region 时取交集(该 Region 下且属于这些 Company 的门店), + /// 禁止并集——否则回显「区内全选」无法折叠为 locationIds:["ALL"]。 + /// public static async Task> ResolveAllLocationIdsAsync( ISqlSugarClient db, IReadOnlyList? partnerIds, IReadOnlyList? regionIds) { + var partners = LocationScopeBindingHelper.NormalizeIds(partnerIds); + var regions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + + if (regions.Count > 0) + { + var fromRegions = await LocationScopeBindingHelper.ResolveLocationIdsFromGroupIdsAsync(db, regions); + if (partners.Count == 0) + { + return LocationScopeBindingHelper.NormalizeIds(fromRegions); + } + + var partnerLocSet = new HashSet( + await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners), + StringComparer.OrdinalIgnoreCase); + return LocationScopeBindingHelper.NormalizeIds( + fromRegions.Where(id => partnerLocSet.Contains(id)).ToList()); + } + + if (partners.Count > 0) + { + return await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners); + } + var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( db, - partnerIds, - regionIds, + (IReadOnlyList?)null, + null, null); return LocationScopeBindingHelper.NormalizeIds(merged); } + /// Id 数组是否含 ALL 哨兵(大小写不敏感;与具体 Guid 同传时以 ALL 为准)。 + public static bool HasAllScopeSentinelSelection(IReadOnlyList? ids) => + LocationScopeBindingHelper.ContainsAllScopeSentinel(ids); + /// Company 维度是否应存为 ALL(含「勾选了全部 Company」)。 public static async Task<(string Type, List Ids)> NormalizePartnerScopeAsync( ISqlSugarClient db, @@ -110,6 +158,10 @@ public static class AllScopeBindingHelper bool hasArrayInPayload) { var ids = LabelEntityPartnerScopeHelper.NormalizePartnerIds(partnerIds, companyIds); + if (HasAllScopeSentinelSelection(ids)) + { + return (ScopeAll, new List()); + } var allPartners = await ResolveAllPartnerIdsAsync(db); // 勾选当前全部 Company(含前端仍传 SPECIFIED + 全量 Id)→ 归档 ALL,后续新增 Company 动态适用 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allPartners); @@ -125,7 +177,12 @@ public static class AllScopeBindingHelper bool hasArrayInPayload, IReadOnlyList? partnerIdsForContext) { - var ids = LocationScopeBindingHelper.NormalizeIds(regionIds); + if (HasAllScopeSentinelSelection(regionIds)) + { + return (ScopeAll, new List()); + } + + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); var allRegions = await ResolveAllRegionIdsAsync(db, partnerIdsForContext); var isFull = ids.Count > 0 && IsFullIdSelection(ids, allRegions); var type = ResolveDimensionType(declaredType, ids, hasArrayInPayload, isFull); @@ -141,7 +198,12 @@ public static class AllScopeBindingHelper IReadOnlyList? partnerIdsForContext, IReadOnlyList? regionIdsForContext) { - var ids = LocationScopeBindingHelper.NormalizeIds(locationIds); + if (HasAllScopeSentinelSelection(locationIds)) + { + return (ScopeAll, new List()); + } + + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds); var allLocations = await ResolveAllLocationIdsAsync(db, partnerIdsForContext, regionIdsForContext); var isFull = ids.Count > 0 && IsFullIdSelection(ids, allLocations); var type = ResolveDimensionType(declaredType, ids, hasArrayInPayload, isFull); @@ -162,7 +224,7 @@ public static class AllScopeBindingHelper return new List { partnerId.Trim() }; } - var regionIds = LocationScopeBindingHelper.NormalizeIds(regionOrGroupIds); + var regionIds = LocationScopeBindingHelper.FilterConcreteScopeIds(regionOrGroupIds); if (regionIds.Count > 0) { var rows = await db.Queryable() @@ -173,7 +235,7 @@ public static class AllScopeBindingHelper return normalized.Count > 0 ? normalized : null; } - var locIds = LocationScopeBindingHelper.NormalizeIds(locationIds); + var locIds = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds); if (locIds.Count == 0) { return null; @@ -195,27 +257,55 @@ public static class AllScopeBindingHelper bool hasScopeArrays, IReadOnlyList? partnerIdsForContext) { + var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds); + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + + // 具体 Region(非全局 ALL)下即使覆盖该区全部门店,也只存 SPECIFIED 快照,不升全局 AvailabilityType=ALL + if (concreteRegions.Count > 0) + { + return false; + } + + // locationIds 含 ALL 且无具体 Region:由调用方按 Company 展开或归档全局 ALL + if (HasAllScopeSentinelSelection(locationIds)) + { + return true; + } + + if (HasAllScopeSentinelSelection(regionIds) && concreteLocations.Count == 0) + { + return true; + } + if (IsDeclaredAll(declaredAvailabilityType) - && LocationScopeBindingHelper.NormalizeIds(regionIds).Count == 0 - && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0) + && concreteRegions.Count == 0 + && concreteLocations.Count == 0) { return true; } if (hasScopeArrays - && LocationScopeBindingHelper.NormalizeIds(regionIds).Count == 0 - && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0 + && concreteRegions.Count == 0 + && concreteLocations.Count == 0 && IsDeclaredAll(declaredAvailabilityType)) { return true; } - // 前端 Select All 常传 SPECIFIED + 当前全量 Id:覆盖 Company 上下文全部门店时归档 ALL - var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( + // 前端 Select All 常传 SPECIFIED + 当前全量 Id:覆盖上下文全部门店时归档 ALL。 + // 有具体 locationIds 时:只按这些门店落 SPECIFIED 快照,禁止再升成 AvailabilityType=ALL + // (否则「Region=ALL + 选 1 店」在公司仅 1 店或误判全选时会清空快照并回显成全局 ALL)。 + if (concreteLocations.Count > 0) + { + return false; + } + + List merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( db, (IReadOnlyList?)null, - regionIds, - locationIds); + concreteRegions, + concreteLocations); + if (merged.Count == 0) { return false; @@ -224,14 +314,14 @@ public static class AllScopeBindingHelper var partnerContext = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext); if (partnerContext.Count == 0) { - partnerContext = await ResolvePartnerContextFromScopeAsync(db, null, regionIds, locationIds) + partnerContext = await ResolvePartnerContextFromScopeAsync(db, null, concreteRegions, concreteLocations) ?? new List(); } var allLocations = await ResolveAllLocationIdsAsync( db, partnerContext.Count > 0 ? partnerContext : null, - null); + concreteRegions.Count > 0 ? concreteRegions : null); return IsFullIdSelection(merged, allLocations); } @@ -244,10 +334,35 @@ public static class AllScopeBindingHelper IReadOnlyList? locationIds, bool hasScopePayload) { + // partnerId 字符串为 ALL 哨兵:全选 Company,归档 ALL(禁止把 ALL 当 Guid 查库) + if (LocationScopeBindingHelper.IsAllScopeSentinel(partnerId)) + { + return true; + } + + var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds); + var concreteGroups = LocationScopeBindingHelper.FilterConcreteScopeIds(groupIds); + + // 具体 Region 下不升全局 AvailabilityType=ALL(与 ShouldTreatMergedLocationScopeAsAllAsync 一致) + if (concreteGroups.Count > 0) + { + return false; + } + + if (HasAllScopeSentinelSelection(locationIds)) + { + return true; + } + + if (HasAllScopeSentinelSelection(groupIds) && concreteLocations.Count == 0) + { + return true; + } + if (IsDeclaredAll(declaredAvailabilityType) && string.IsNullOrWhiteSpace(partnerId) - && LocationScopeBindingHelper.NormalizeIds(groupIds).Count == 0 - && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0) + && concreteGroups.Count == 0 + && concreteLocations.Count == 0) { return true; } @@ -257,20 +372,29 @@ public static class AllScopeBindingHelper return false; } + // 有具体门店时不升全局 ALL + if (concreteLocations.Count > 0) + { + return false; + } + var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( db, partnerId, - groupIds, - locationIds); + concreteGroups, + concreteLocations); + if (merged.Count == 0) { return false; } - // 未传 partnerId 时从 Region/门店反推 Company,避免用「全系统门店」误判导致无法归档 ALL var partnerContext = await ResolvePartnerContextFromScopeAsync( - db, partnerId, groupIds, locationIds); - var allLocations = await ResolveAllLocationIdsAsync(db, partnerContext, null); + db, partnerId, concreteGroups, concreteLocations); + var allLocations = await ResolveAllLocationIdsAsync( + db, + partnerContext, + concreteGroups.Count > 0 ? concreteGroups : null); return IsFullIdSelection(merged, allLocations); } @@ -302,4 +426,169 @@ public static class AllScopeBindingHelper : await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, locs); return (partnerIds, regionIds, locs); } + + /// + /// 标签/产品分类等:解析 Region + Location 落库(对齐 Label:Region=ALL 时可保留具体门店快照)。 + /// + public sealed class LabelEntityRegionLocationSaveResult + { + public string AvailabilityType { get; init; } = ScopeSpecified; + + public string AppliedRegionType { get; init; } = ScopeAll; + + public List LocationIds { get; init; } = new(); + } + + public static async Task ResolveLabelEntityRegionLocationForSaveAsync( + ISqlSugarClient db, + string? declaredAvailabilityType, + IReadOnlyList? partnerIdsForContext, + IReadOnlyList? mergedRegionIdsRaw, + IReadOnlyList? locationIdsRaw, + bool hasScopeArrays) + { + var normalizedLocationIds = LocationScopeBindingHelper.NormalizeIds(locationIdsRaw); + var mergedRegionIds = LocationScopeBindingHelper.NormalizeIds(mergedRegionIdsRaw); + var regionIds = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedRegionIds); + var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(normalizedLocationIds); + var locationHasAll = HasAllScopeSentinelSelection(normalizedLocationIds); + var regionHasAllSentinel = HasAllScopeSentinelSelection(mergedRegionIds); + var hasConcretePartner = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext).Count > 0; + // 用户显式传具体门店 Id 时一律 SPECIFIED,不因「恰好覆盖 Region 全集」归档 ALL + var userExplicitLocations = explicitLocationIds.Count > 0 && !locationHasAll; + + // 前端 Select All Region 常展开为全量 Guid,需识别为 Region=ALL + var regionHasAll = regionHasAllSentinel; + if (!regionHasAll && regionIds.Count > 0) + { + var allRegions = await ResolveAllRegionIdsAsync( + db, + hasConcretePartner ? partnerIdsForContext : null); + regionHasAll = allRegions.Count > 0 && IsFullIdSelection(regionIds, allRegions); + } + + // location ALL 哨兵,或具体 Region 下「空选/全选门店」:有 Company/Region 上下文则展开 SPECIFIED + var locationCoversConcreteRegions = false; + if (!locationHasAll && !userExplicitLocations && regionIds.Count > 0 && !regionHasAll) + { + if (explicitLocationIds.Count == 0) + { + locationCoversConcreteRegions = true; + } + else + { + var regionUniverse = await ResolveAllLocationIdsAsync( + db, + hasConcretePartner ? partnerIdsForContext : null, + regionIds); + locationCoversConcreteRegions = regionUniverse.Count > 0 + && IsFullIdSelection(explicitLocationIds, regionUniverse); + } + } + + if (!userExplicitLocations && (locationHasAll || locationCoversConcreteRegions)) + { + var expandRegions = regionHasAll ? null : (regionIds.Count > 0 ? regionIds : null); + var expanded = await LocationScopeBindingHelper.ExpandScopedAllLocationsForSaveAsync( + db, + hasConcretePartner ? partnerIdsForContext : null, + expandRegions); + if (expanded is not null) + { + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeSpecified, + AppliedRegionType = regionHasAll || regionIds.Count == 0 ? ScopeAll : ScopeSpecified, + LocationIds = expanded + }; + } + + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeAll, + AppliedRegionType = ScopeAll, + LocationIds = new List() + }; + } + + // Region=ALL + 具体门店:Region 存 ALL,门店 SPECIFIED 快照(可回显 regionIds=["ALL"] + 单个 locationId) + if (regionHasAll && explicitLocationIds.Count > 0) + { + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds); + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeSpecified, + AppliedRegionType = ScopeAll, + LocationIds = explicitLocationIds + }; + } + + // Region=ALL 且无具体门店 → 双 ALL + if (regionHasAll) + { + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeAll, + AppliedRegionType = ScopeAll, + LocationIds = new List() + }; + } + + // 有具体门店(可同传具体 Region):以门店为准 + if (explicitLocationIds.Count > 0) + { + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds); + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeSpecified, + AppliedRegionType = ScopeSpecified, + LocationIds = explicitLocationIds + }; + } + + if (await ShouldTreatMergedLocationScopeAsAllAsync( + db, + declaredAvailabilityType, + regionIds, + explicitLocationIds, + hasScopeArrays, + partnerIdsForContext)) + { + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeAll, + AppliedRegionType = ScopeAll, + LocationIds = new List() + }; + } + + var availabilityType = (declaredAvailabilityType ?? ScopeAll).Trim().ToUpperInvariant(); + if (regionIds.Count > 0) + { + availabilityType = ScopeSpecified; + } + else if (hasScopeArrays && IsDeclaredAll(availabilityType)) + { + availabilityType = ScopeAll; + } + + if (availabilityType != ScopeAll && availabilityType != ScopeSpecified) + { + throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); + } + + var locationSpecified = string.Equals(availabilityType, ScopeSpecified, StringComparison.OrdinalIgnoreCase); + var savedLocationIds = await LocationScopeBindingHelper.ResolveEntityLocationIdsForSaveAsync( + db, + locationSpecified, + regionIds, + explicitLocationIds); + + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = locationSpecified ? ScopeSpecified : ScopeAll, + AppliedRegionType = locationSpecified ? ScopeSpecified : ScopeAll, + LocationIds = savedLocationIds + }; + } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/EntityLocationScopeDisplayHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/EntityLocationScopeDisplayHelper.cs index c53fee6..92fb678 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/EntityLocationScopeDisplayHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/EntityLocationScopeDisplayHelper.cs @@ -11,6 +11,10 @@ public static class EntityLocationScopeDisplayHelper /// /// 根据可用范围类型与已解析的 Region/Location Id、名称,生成列表展示文案。 /// + /// + /// 有具体门店 Guid 时始终展示门店名称,不因「覆盖某 Region 下全部门店」误显示 All Location + /// (常见于 AppliedRegionType=ALL + 单个 locationIds)。 + /// public static async Task<(string Region, string Location)> BuildListDisplayAsync( ISqlSugarClient db, string? availabilityType, @@ -18,7 +22,8 @@ public static class EntityLocationScopeDisplayHelper IReadOnlyList locationIds, IEnumerable regionNames, IEnumerable locationNames, - IReadOnlyList? partnerIdsForContext) + IReadOnlyList? partnerIdsForContext, + string? appliedRegionType = null) { if (AllScopeBindingHelper.IsDeclaredAll(availabilityType)) { @@ -29,27 +34,33 @@ public static class EntityLocationScopeDisplayHelper var normLocationIds = LocationScopeBindingHelper.NormalizeIds(locationIds); var partnerContext = partnerIdsForContext is { Count: > 0 } ? partnerIdsForContext : null; - var allRegionIds = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, partnerContext); - var regionIsAll = normRegionIds.Count > 0 - && allRegionIds.Count > 0 - && AllScopeBindingHelper.IsFullIdSelection(normRegionIds, allRegionIds); - - var regionContextForLocations = regionIsAll ? null : normRegionIds.Count > 0 ? normRegionIds : null; - var allLocationIds = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( - db, - partnerContext, - regionContextForLocations); - var locationIsAll = normLocationIds.Count > 0 - && allLocationIds.Count > 0 - && AllScopeBindingHelper.IsFullIdSelection(normLocationIds, allLocationIds); - - var regionDisplay = regionIsAll - ? AllScopeBindingHelper.AllRegionsDisplay - : JoinDistinctNames(regionNames); + string regionDisplay; + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType) + || AllScopeBindingHelper.HasAllScopeSentinelSelection(normRegionIds)) + { + regionDisplay = AllScopeBindingHelper.AllRegionsDisplay; + } + else + { + var allRegionIds = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, partnerContext); + var regionIsAll = normRegionIds.Count > 0 + && allRegionIds.Count > 0 + && AllScopeBindingHelper.IsFullIdSelection(normRegionIds, allRegionIds); + regionDisplay = regionIsAll + ? AllScopeBindingHelper.AllRegionsDisplay + : JoinDistinctNames(regionNames); + } - var locationDisplay = locationIsAll - ? AllScopeBindingHelper.AllLocationsDisplay - : JoinDistinctNames(locationNames); + // 具体门店 Guid → 显示门店名;仅 AvailabilityType=ALL 或 locationIds 含 ALL 哨兵才显示 All Location + string locationDisplay; + if (AllScopeBindingHelper.HasAllScopeSentinelSelection(normLocationIds)) + { + locationDisplay = AllScopeBindingHelper.AllLocationsDisplay; + } + else + { + locationDisplay = JoinDistinctNames(locationNames); + } return (regionDisplay, locationDisplay); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelAlertTimerWriteHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelAlertTimerWriteHelper.cs new file mode 100644 index 0000000..2c945b6 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelAlertTimerWriteHelper.cs @@ -0,0 +1,148 @@ +using System.Globalization; +using FoodLabeling.Application.Services.DbModels; +using FoodLabeling.Domain.Shared.Helpers; +using SqlSugar; + +namespace FoodLabeling.Application.Helpers; + +/// +/// 打印成功后写入告警计时器(按 BatchId 幂等,一条批次一条计时器)。 +/// +public static class LabelAlertTimerWriteHelper +{ + /// + /// 根据打印批次创建告警计时器;已存在未删除记录或无有效过期时刻时直接返回。 + /// + public static async Task TryCreateFromPrintBatchAsync( + ISqlSugarClient db, + string batchId, + string? createdBy, + CancellationToken ct = default) + { + var bid = batchId?.Trim(); + if (string.IsNullOrWhiteSpace(bid)) + { + return; + } + + ct.ThrowIfCancellationRequested(); + + // BatchId 有唯一索引:含软删记录也不再插入,避免幂等补写撞唯一约束 + var exists = await db.Queryable() + .AnyAsync(x => x.BatchId == bid); + if (exists) + { + return; + } + + var task = (await db.Queryable() + .Where(x => x.BatchId == bid) + .OrderBy(x => x.CopyIndex) + .Take(1) + .ToListAsync(ct)) + .FirstOrDefault(); + if (task is null) + { + return; + } + + var printedAt = task.PrintedAt ?? task.BaseTime ?? task.CreationTime; + if (!ReportsPrintLogExpiryHelper.TryResolveExpiryDateTime( + task.PrintInputJson, + task.RenderTemplateJson, + task.BaseTime, + printedAt, + out var expiresAt)) + { + return; + } + + var label = (await db.Queryable() + .Where(x => x.Id == task.LabelId) + .Select(x => new { x.LabelName, x.LabelCode }) + .Take(1) + .ToListAsync(ct)) + .FirstOrDefault(); + + var labelName = label?.LabelName?.Trim(); + if (string.IsNullOrWhiteSpace(labelName)) + { + labelName = FoodLabelingDisplayConsts.NotAvailable; + } + + string? productName = null; + if (!string.IsNullOrWhiteSpace(task.ProductId)) + { + productName = (await db.Queryable() + .Where(x => x.Id == task.ProductId && !x.IsDeleted) + .Select(x => x.ProductName) + .Take(1) + .ToListAsync(ct)) + .FirstOrDefault(); + } + + var durationSeconds = Math.Max(0, (int)(expiresAt - printedAt).TotalSeconds); + var title = BuildTitle(labelName, durationSeconds); + var subtitle = BuildSubtitle(durationSeconds, expiresAt); + var now = DateTime.Now; + + var entity = new FlLabelAlertTimerDbEntity + { + Id = YitIdHelper.NextId().ToString(), + BatchId = bid, + PrintTaskId = task.Id, + LabelId = task.LabelId, + LabelCode = label?.LabelCode?.Trim(), + LabelName = labelName, + ProductId = task.ProductId, + ProductName = string.IsNullOrWhiteSpace(productName) ? null : productName.Trim(), + LocationId = task.LocationId?.Trim() ?? string.Empty, + PrintedAt = printedAt, + BaseTime = task.BaseTime, + ExpiresAt = expiresAt, + DurationSeconds = durationSeconds, + Title = title, + Subtitle = subtitle, + IsDeleted = false, + DeletionTime = null, + CreatedBy = createdBy, + CreationTime = now + }; + + await db.Insertable(entity).ExecuteCommandAsync(); + } + + private static string BuildTitle(string labelName, int durationSeconds) + { + var hoursText = FormatDurationHoursLabel(durationSeconds); + return string.IsNullOrWhiteSpace(hoursText) ? labelName : $"{labelName} ({hoursText})"; + } + + private static string BuildSubtitle(int durationSeconds, DateTime expiresAt) + { + var hoursText = FormatDurationHoursLabel(durationSeconds); + var timeText = expiresAt.ToString("h:mm tt", CultureInfo.CurrentCulture); + return string.IsNullOrWhiteSpace(hoursText) + ? $"Completes at {timeText}" + : $"{hoursText} Completes at {timeText}"; + } + + private static string FormatDurationHoursLabel(int durationSeconds) + { + if (durationSeconds <= 0) + { + return string.Empty; + } + + var totalHours = durationSeconds / 3600.0; + if (totalHours >= 1) + { + var rounded = (int)Math.Round(totalHours, MidpointRounding.AwayFromZero); + rounded = Math.Max(1, rounded); + return rounded == 1 ? "1 hour" : $"{rounded} hours"; + } + + var minutes = Math.Max(1, (int)Math.Ceiling(durationSeconds / 60.0)); + return minutes == 1 ? "1 minute" : $"{minutes} minutes"; + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityListScopeHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityListScopeHelper.cs index d8c8296..5b75c4a 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityListScopeHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityListScopeHelper.cs @@ -4,12 +4,37 @@ using SqlSugar; namespace FoodLabeling.Application.Helpers; /// -/// 标签类型/分类/多选项列表:非平台管理员仅可见其绑定 Company 范围内数据。 +/// 标签类型/分类/多选项列表:按可见门店范围过滤。 +/// AvailabilityType=ALL 时传任意 Region/Location 筛选均命中。 /// public static class LabelEntityListScopeHelper { /// - /// 标签类型列表:按 Company + Region/Location 范围过滤(Company Admin 不可见 All Companies 数据)。 + /// 是否应用门店 Availability 过滤。 + /// 仅传 partnerId(未传 groupId/locationId)时返回 false:只按 Company 维度筛, + /// 避免「多公司绑定」在第二家公司暂无门店或门店未落入关联表时被误杀。 + /// + public static bool ShouldApplyLocationAvailabilityFilter( + string? partnerId, + string? groupId, + string? locationId) + { + if (!string.IsNullOrWhiteSpace(groupId) || !string.IsNullOrWhiteSpace(locationId)) + { + return true; + } + + // 仅 PartnerId:Company 过滤由 Apply*PartnerListFilter 负责 + if (!string.IsNullOrWhiteSpace(partnerId)) + { + return false; + } + + return true; + } + + /// + /// 标签类型列表:门店可用范围过滤(ALL 命中任意门店筛选)。 /// public static ISugarQueryable ApplyTypeLocationAvailabilityFilter( ISugarQueryable query, @@ -22,12 +47,12 @@ public static class LabelEntityListScopeHelper if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + // 无可见门店时仍保留 AvailabilityType=ALL(仅 Company 筛选时公司可能暂无门店) + return query.Where(t => t.AvailabilityType == AllScopeBindingHelper.ScopeAll); } return query.Where(t => - (t.AvailabilityType == AllScopeBindingHelper.ScopeAll - && t.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified) + t.AvailabilityType == AllScopeBindingHelper.ScopeAll || (t.AvailabilityType == AllScopeBindingHelper.ScopeSpecified && SqlFunc.Subqueryable() .Where(tl => tl.LabelTypeId == t.Id && scopedLocationIds.Contains(tl.LocationId)) @@ -35,7 +60,7 @@ public static class LabelEntityListScopeHelper } /// - /// 标签分类列表:按 Company + Region/Location 范围过滤。 + /// 标签分类列表:门店可用范围过滤(ALL 命中任意门店筛选)。 /// public static ISugarQueryable ApplyCategoryLocationAvailabilityFilter( ISugarQueryable query, @@ -48,12 +73,11 @@ public static class LabelEntityListScopeHelper if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + return query.Where(c => c.AvailabilityType == AllScopeBindingHelper.ScopeAll); } return query.Where(c => - (c.AvailabilityType == AllScopeBindingHelper.ScopeAll - && c.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified) + c.AvailabilityType == AllScopeBindingHelper.ScopeAll || (c.AvailabilityType == AllScopeBindingHelper.ScopeSpecified && SqlFunc.Subqueryable() .Where(cl => cl.CategoryId == c.Id && scopedLocationIds.Contains(cl.LocationId)) @@ -61,7 +85,7 @@ public static class LabelEntityListScopeHelper } /// - /// 标签多选项列表:按 Company + Region/Location 范围过滤。 + /// 标签多选项列表:门店可用范围过滤(ALL 命中任意门店筛选)。 /// public static ISugarQueryable ApplyMultipleOptionLocationAvailabilityFilter( ISugarQueryable query, @@ -74,12 +98,11 @@ public static class LabelEntityListScopeHelper if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + return query.Where(o => o.AvailabilityType == AllScopeBindingHelper.ScopeAll); } return query.Where(o => - (o.AvailabilityType == AllScopeBindingHelper.ScopeAll - && o.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified) + o.AvailabilityType == AllScopeBindingHelper.ScopeAll || (o.AvailabilityType == AllScopeBindingHelper.ScopeSpecified && SqlFunc.Subqueryable() .Where(ol => ol.MultipleOptionId == o.Id && scopedLocationIds.Contains(ol.LocationId)) diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityPartnerScopeHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityPartnerScopeHelper.cs index 6498f4a..6483617 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityPartnerScopeHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityPartnerScopeHelper.cs @@ -26,7 +26,10 @@ public static class LabelEntityPartnerScopeHelper Category, /// 标签多选项 fl_label_multiple_option - MultipleOption + MultipleOption, + + /// 产品分类 fl_product_category + ProductCategory } public sealed class LabelEntityPartnerScopeSaveResult @@ -60,6 +63,10 @@ public static class LabelEntityPartnerScopeHelper public bool HasCategoryPartnerTable { get; init; } public bool HasMultipleOptionPartnerTable { get; init; } + + public bool HasProductCategoryPartnerColumn { get; init; } + + public bool HasProductCategoryPartnerTable { get; init; } } /// @@ -185,7 +192,7 @@ public static class LabelEntityPartnerScopeHelper if (needsPartnerRows && !hasTable) { throw new UserFriendlyException( - "Company 适用范围关联表尚未就绪,请联系管理员执行数据库迁移(fl_label_entity_partner_scope.sql)后重试"); + "Company 适用范围关联表尚未就绪,请联系管理员执行数据库迁移(fl_label_entity_partner_scope.sql 或 fl_product_category_partner_scope.sql)后重试"); } if (hasTable) @@ -325,11 +332,30 @@ public static class LabelEntityPartnerScopeHelper ? AllCompaniesDisplay : BuildCompanyDisplay(pIds, partnerNameById); + List displayPartnerIds; + if (string.Equals(partnerType, ScopeAll, StringComparison.OrdinalIgnoreCase)) + { + displayPartnerIds = new List { AllScopeBindingHelper.ScopeAll }; + } + else + { + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, + pIds, + null, + null, + new ScopeAllEchoHelper.ScopeAllEchoOptions + { + AppliedPartnerType = partnerType + }); + displayPartnerIds = collapsed.PartnerIds; + } + result[entityId] = new LabelEntityPartnerScopeDisplay { Company = companyDisplay, AppliedPartnerType = partnerType, - PartnerIds = pIds + PartnerIds = displayPartnerIds }; } @@ -383,11 +409,13 @@ public static class LabelEntityPartnerScopeHelper return query.Where(_ => false); } + // Company=ALL:传任意 partnerId 均命中;SPECIFIED:须与关联表有交集 return query.Where(t => - t.AppliedPartnerType == ScopeSpecified - && SqlFunc.Subqueryable() - .Where(p => p.LabelTypeId == t.Id && scopedPartnerIds.Contains(p.PartnerId)) - .Any()); + t.AppliedPartnerType == ScopeAll + || (t.AppliedPartnerType == ScopeSpecified + && SqlFunc.Subqueryable() + .Where(p => p.LabelTypeId == t.Id && scopedPartnerIds.Contains(p.PartnerId)) + .Any())); } /// @@ -415,10 +443,11 @@ public static class LabelEntityPartnerScopeHelper } return query.Where(c => - c.AppliedPartnerType == ScopeSpecified - && SqlFunc.Subqueryable() - .Where(p => p.CategoryId == c.Id && scopedPartnerIds.Contains(p.PartnerId)) - .Any()); + c.AppliedPartnerType == ScopeAll + || (c.AppliedPartnerType == ScopeSpecified + && SqlFunc.Subqueryable() + .Where(p => p.CategoryId == c.Id && scopedPartnerIds.Contains(p.PartnerId)) + .Any())); } /// @@ -446,14 +475,48 @@ public static class LabelEntityPartnerScopeHelper } return query.Where(o => - o.AppliedPartnerType == ScopeSpecified - && SqlFunc.Subqueryable() - .Where(p => p.MultipleOptionId == o.Id && scopedPartnerIds.Contains(p.PartnerId)) - .Any()); + o.AppliedPartnerType == ScopeAll + || (o.AppliedPartnerType == ScopeSpecified + && SqlFunc.Subqueryable() + .Where(p => p.MultipleOptionId == o.Id && scopedPartnerIds.Contains(p.PartnerId)) + .Any())); + } + + /// + /// 列表筛选:按可见 Company 过滤产品分类。 + /// + public static async Task> ApplyProductCategoryPartnerListFilterAsync( + ISqlSugarClient db, + ISugarQueryable query, + IReadOnlyList? scopedPartnerIds) + { + if (scopedPartnerIds is null) + { + return query; + } + + var schema = await GetSchemaStatusAsync(db); + if (!HasPartnerTableForKind(schema, LabelEntityPartnerKind.ProductCategory)) + { + return query; + } + + if (scopedPartnerIds.Count == 0) + { + return query.Where(_ => false); + } + + return query.Where(c => + c.AppliedPartnerType == ScopeAll + || (c.AppliedPartnerType == ScopeSpecified + && SqlFunc.Subqueryable() + .Where(p => p.CategoryId == c.Id && scopedPartnerIds.Contains(p.PartnerId)) + .Any())); } /// /// 由 Query partnerId 解析列表 Company 筛选 Id;未传则返回 null。 + /// Id 统一为小写 Guid,避免与关联表大小写不一致导致 IN 匹配失败。 /// public static async Task?> ResolveScopedPartnerIdsForListAsync( ISqlSugarClient db, @@ -467,7 +530,13 @@ public static class LabelEntityPartnerScopeHelper var exists = await db.Queryable() .AnyAsync(x => !x.IsDeleted && x.Id == pid); - return exists ? new List { pid } : new List(); + if (!exists) + { + return new List(); + } + + var key = TeamMemberListScopeHelper.NormalizeScopeKey(pid); + return string.IsNullOrEmpty(key) ? new List { pid } : new List { key }; } /// @@ -514,6 +583,11 @@ public static class LabelEntityPartnerScopeHelper .Where(x => x.MultipleOptionId == entityId) .ExecuteCommandAsync(); break; + case LabelEntityPartnerKind.ProductCategory: + await db.Deleteable() + .Where(x => x.CategoryId == entityId) + .ExecuteCommandAsync(); + break; } } @@ -564,7 +638,10 @@ public static class LabelEntityPartnerScopeHelper db, "fl_label_multiple_option", "AppliedPartnerType"), HasTypePartnerTable = await TableExistsAsync(db, "fl_label_type_partner"), HasCategoryPartnerTable = await TableExistsAsync(db, "fl_label_category_partner"), - HasMultipleOptionPartnerTable = await TableExistsAsync(db, "fl_label_multiple_option_partner") + HasMultipleOptionPartnerTable = await TableExistsAsync(db, "fl_label_multiple_option_partner"), + HasProductCategoryPartnerColumn = await ColumnExistsAsync( + db, "fl_product_category", "AppliedPartnerType"), + HasProductCategoryPartnerTable = await TableExistsAsync(db, "fl_product_category_partner") }; } catch @@ -581,6 +658,7 @@ public static class LabelEntityPartnerScopeHelper LabelEntityPartnerKind.Type => schema.HasTypePartnerTable, LabelEntityPartnerKind.Category => schema.HasCategoryPartnerTable, LabelEntityPartnerKind.MultipleOption => schema.HasMultipleOptionPartnerTable, + LabelEntityPartnerKind.ProductCategory => schema.HasProductCategoryPartnerTable, _ => false }; @@ -590,6 +668,7 @@ public static class LabelEntityPartnerScopeHelper LabelEntityPartnerKind.Type => schema.HasTypePartnerColumn, LabelEntityPartnerKind.Category => schema.HasCategoryPartnerColumn, LabelEntityPartnerKind.MultipleOption => schema.HasMultipleOptionPartnerColumn, + LabelEntityPartnerKind.ProductCategory => schema.HasProductCategoryPartnerColumn, _ => false }; @@ -637,6 +716,7 @@ public static class LabelEntityPartnerScopeHelper LabelEntityPartnerKind.Type => "fl_label_type", LabelEntityPartnerKind.Category => "fl_label_category", LabelEntityPartnerKind.MultipleOption => "fl_label_multiple_option", + LabelEntityPartnerKind.ProductCategory => "fl_product_category", _ => string.Empty }; @@ -666,6 +746,7 @@ public static class LabelEntityPartnerScopeHelper LabelEntityPartnerKind.Type => "fl_label_type", LabelEntityPartnerKind.Category => "fl_label_category", LabelEntityPartnerKind.MultipleOption => "fl_label_multiple_option", + LabelEntityPartnerKind.ProductCategory => "fl_product_category", _ => string.Empty }; @@ -709,6 +790,13 @@ public static class LabelEntityPartnerScopeHelper .ToListAsync(); return rows.Select(x => (x.MultipleOptionId, x.PartnerId)).ToList(); } + case LabelEntityPartnerKind.ProductCategory: + { + var rows = await db.Queryable() + .Where(x => entityIds.Contains(x.CategoryId)) + .ToListAsync(); + return rows.Select(x => (x.CategoryId, x.PartnerId)).ToList(); + } default: return new List<(string EntityId, string PartnerId)>(); } @@ -764,6 +852,19 @@ public static class LabelEntityPartnerScopeHelper await db.Insertable(rows).ExecuteCommandAsync(); break; } + case LabelEntityPartnerKind.ProductCategory: + { + var rows = partnerIds.Select(pid => new FlProductCategoryPartnerDbEntity + { + Id = guidGenerator.Create().ToString(), + CategoryId = entityId, + PartnerId = pid, + CreationTime = now, + CreatorId = currentUserId + }).ToList(); + await db.Insertable(rows).ExecuteCommandAsync(); + break; + } } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelQueryHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelQueryHelper.cs index d7f7ce9..b3bc4f3 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelQueryHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelQueryHelper.cs @@ -28,6 +28,7 @@ public static class LabelQueryHelper LabelName = x.LabelName, TemplateId = x.TemplateId, LocationId = x.LocationId, + PartnerId = x.PartnerId, LabelCategoryId = x.LabelCategoryId, LabelTypeId = x.LabelTypeId, LabelType = x.LabelType, diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelRegionScopeHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelRegionScopeHelper.cs index 09fd63b..d38cc65 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelRegionScopeHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelRegionScopeHelper.cs @@ -58,12 +58,40 @@ public static class LabelRegionScopeHelper IReadOnlyList? regionIds, IReadOnlyList? groupIds, string? locationId, - IReadOnlyList? locationIds) + IReadOnlyList? locationIds, + IReadOnlyList? partnerIdsForContext = null) { - var mergedRegionIds = NormalizeRegionIds(regionIds, groupIds); - var explicitLocationIds = MergeExplicitLocationIds(locationId, locationIds); + var mergedRegionIdsRaw = NormalizeRegionIds(regionIds, groupIds); + var explicitLocationIdsRaw = MergeExplicitLocationIds(locationId, locationIds); + var regionHasAll = LocationScopeBindingHelper.ContainsAllScopeSentinel(mergedRegionIdsRaw); + var locationHasAll = LocationScopeBindingHelper.ContainsAllScopeSentinel(explicitLocationIdsRaw); + var mergedRegionIds = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedRegionIdsRaw); + var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(explicitLocationIdsRaw); var type = (appliedRegionType ?? AppliedRegionAll).Trim().ToUpperInvariant(); var hasScopeArrays = regionIds is not null || groupIds is not null || locationIds is not null; + var partnerContext = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext); + + // locationIds / regionIds 含 ALL 哨兵:不归档为 Guid 校验,按全选处理(Company 由 fl_label.PartnerId 落库) + if (locationHasAll || (regionHasAll && explicitLocationIds.Count == 0)) + { + return new LabelRegionScopeSaveResult + { + AppliedRegionType = AppliedRegionAll, + RegionIds = new List(), + LocationIds = new List() + }; + } + + if (regionHasAll && explicitLocationIds.Count > 0) + { + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds); + return new LabelRegionScopeSaveResult + { + AppliedRegionType = AppliedRegionAll, + RegionIds = new List(), + LocationIds = explicitLocationIds + }; + } if (mergedRegionIds.Count > 0) { @@ -95,8 +123,10 @@ public static class LabelRegionScopeHelper await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds); } - var partnerContext = await ResolvePartnerContextAsync(db, mergedRegionIds, explicitLocationIds); - var allRegions = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, partnerContext); + var resolvedPartnerContext = partnerContext.Count > 0 + ? partnerContext + : await ResolvePartnerContextAsync(db, mergedRegionIds, explicitLocationIds); + var allRegions = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, resolvedPartnerContext); var regionsFull = mergedRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(mergedRegionIds, allRegions); @@ -107,7 +137,7 @@ public static class LabelRegionScopeHelper if (declaredOrFullAll) { var allLocationsForPartner = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( - db, partnerContext, null); + db, resolvedPartnerContext, null); var locationsFullForPartner = explicitLocationIds.Count == 0 || (allLocationsForPartner.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocationsForPartner)); @@ -135,7 +165,7 @@ public static class LabelRegionScopeHelper if (mergedRegionIds.Count == 0 && explicitLocationIds.Count > 0) { var allLocations = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( - db, partnerContext, null); + db, resolvedPartnerContext, null); if (allLocations.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocations)) { @@ -157,7 +187,7 @@ public static class LabelRegionScopeHelper // 部分 Region:门店 Select All(空或覆盖该区域全集)→ 只落 Region,不写门店快照(新区店靠 Region 动态匹配) var allLocationsInRegions = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( - db, null, mergedRegionIds); + db, resolvedPartnerContext, mergedRegionIds); var locationsFullInRegions = explicitLocationIds.Count == 0 || (allLocationsInRegions.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocationsInRegions)); @@ -179,7 +209,7 @@ public static class LabelRegionScopeHelper // 部分 Region + 部分门店 → 校验门店属于所选 Region 后落快照 var mergedLocations = await LocationScopeBindingHelper.MergeToLocationIdsAsync( - db, (IReadOnlyList?)null, mergedRegionIds, explicitLocationIds); + db, resolvedPartnerContext, mergedRegionIds, explicitLocationIds); if (mergedLocations.Count == 0) { throw new UserFriendlyException("指定 Region 下未匹配到有效门店,请检查 Region 或 locationIds"); @@ -405,8 +435,8 @@ public static class LabelRegionScopeHelper return new LabelRegionScopeDisplay { Region = AllRegionsDisplay, - RegionIds = new List(), - GroupIds = new List() + RegionIds = new List { AllScopeBindingHelper.ScopeAll }, + GroupIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -435,11 +465,18 @@ public static class LabelRegionScopeHelper ? string.Join(", ", names.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!.Trim()).Distinct()) : "?"; + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, + null, + regionIds, + locationIds, + ScopeAllEchoHelper.ForLabelRegionScope(appliedRegionType, locationIds)); + return new LabelRegionScopeDisplay { Region = regionText, - RegionIds = regionIds, - GroupIds = regionIds + RegionIds = collapsed.RegionIds, + GroupIds = collapsed.RegionIds }; } @@ -454,7 +491,7 @@ public static class LabelRegionScopeHelper return new LabelLocationScopeDisplay { Location = AllLocationsDisplay, - LocationIds = new List() + LocationIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -482,10 +519,17 @@ public static class LabelRegionScopeHelper ? string.Join(", ", names.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!.Trim()).Distinct()) : "?"; + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, + null, + null, + locationIds, + ScopeAllEchoHelper.ForLabelRegionScope(appliedRegionType, locationIds)); + return new LabelLocationScopeDisplay { Location = locationText, - LocationIds = locationIds.ToList() + LocationIds = collapsed.LocationIds }; } @@ -593,7 +637,24 @@ public static class LabelRegionScopeHelper new { all = AppliedRegionAll, gid }); } - /// ? scoped ?????fl_label_location ???? LocationId?? + /// + /// 无可见门店时:仅保留 AppliedRegionType=ALL 的标签(用于 PartnerId 筛选但该公司暂无门店)。 + /// + public static ISugarQueryable ApplyLabelAllRegionOnlyFilter( + ISqlSugarClient db, + ISugarQueryable query, + LabelRegionSchemaHelper.LabelRegionSchemaStatus schema) + { + if (!schema.HasAppliedRegionTypeColumn) + { + // 未迁移列时无法识别 ALL,保守返回空 + return query.Where(_ => false); + } + + return query.Where("AppliedRegionType = @all", new { all = AppliedRegionAll }); + } + + /// 按 scoped 门店过滤;AppliedRegionType=ALL 对任意门店筛选均命中。 public static ISugarQueryable ApplyLabelLocationListFilter( ISqlSugarClient db, ISugarQueryable query, @@ -602,7 +663,7 @@ public static class LabelRegionScopeHelper { if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + return ApplyLabelAllRegionOnlyFilter(db, query, schema); } return ApplyLabelLocationMatchFilter(query, scopedLocationIds, schema); @@ -648,6 +709,14 @@ public static class LabelRegionScopeHelper var locationIds = await GetLocationIdsForLabelAsync(db, label.Id, label.LocationId); if (locationIds.Count == 0) { + // 指定 Company 的 ALL:仅该公司下门店可用 + if (!string.IsNullOrWhiteSpace(label.PartnerId)) + { + var locPartners = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( + db, new List { locationId.Trim() }); + return locPartners.Contains(label.PartnerId.Trim(), StringComparer.OrdinalIgnoreCase); + } + return true; } @@ -784,6 +853,26 @@ public static class LabelRegionScopeHelper List scopedLocationIds, LabelRegionSchemaHelper.LabelRegionSchemaStatus schema) { + if (schema.HasAppliedRegionTypeColumn && schema.HasLabelLocationTable) + { + return query.Where( + """ + (AppliedRegionType = @all + OR LocationId IN (@locs) + OR EXISTS (SELECT 1 FROM fl_label_location ll WHERE ll.LabelId = fl_label.Id AND ll.LocationId IN (@locs))) + """, + new { all = AppliedRegionAll, locs = scopedLocationIds }); + } + + if (schema.HasAppliedRegionTypeColumn) + { + return query.Where( + """ + (AppliedRegionType = @all OR LocationId IN (@locs)) + """, + new { all = AppliedRegionAll, locs = scopedLocationIds }); + } + if (schema.HasLabelLocationTable) { return query.Where( @@ -829,17 +918,18 @@ public static class LabelRegionScopeHelper private static async Task ValidateRegionIdsExistAsync(ISqlSugarClient db, List regionIds) { - if (regionIds.Count == 0) + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + if (ids.Count == 0) { return; } var count = await db.Queryable() - .Where(g => !g.IsDeleted && regionIds.Contains(g.Id)) + .Where(g => !g.IsDeleted && ids.Contains(g.Id)) .CountAsync(); - if (count != regionIds.Count) + if (count != ids.Count) { - throw new UserFriendlyException("????? Region Id???????"); + throw new UserFriendlyException("存在无效的 Region Id,请刷新后重试"); } } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs index 674a63b..1056ac9 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs @@ -58,43 +58,73 @@ public static class LabelTemplateScopeHelper /// /// 解析新增/编辑入参中的 Company / Region / Location 范围。 + /// Create/Update 共用;regionIdsgroupIdslocationIdsappliedLocationIds 可传哨兵 ALL, + /// 即使 appliedRegionType / appliedLocationSPECIFIED 也会归档为对应维度 ALL 且不写关联快照。 /// public static async Task ResolveScopeForSaveAsync( ISqlSugarClient db, LabelTemplateCreateInputVo input) { - var partnerIds = NormalizePartnerIds(input); - var regionIds = NormalizeRegionIds(input); - var locationIds = MergeExplicitLocationIds(input); + var mergedRegionIds = NormalizeRegionIds(input); + var mergedLocationIds = MergeExplicitLocationIds(input); + var hasRegionArray = input.RegionIds is not null || input.GroupIds is not null; + var hasLocationArray = input.LocationIds is not null || input.AppliedLocationIds is not null; + + // Create/Update 共用:优先识别 ALL 哨兵,避免后续 Count>0 或 Guid 存在性校验误伤编辑回显的 ["ALL"] + var locationHasAll = AllScopeBindingHelper.HasAllScopeSentinelSelection(mergedLocationIds); + var regionHasAll = AllScopeBindingHelper.HasAllScopeSentinelSelection(mergedRegionIds); + var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedLocationIds); var (partnerType, partnerIdsForSave) = await AllScopeBindingHelper.NormalizePartnerScopeAsync( db, input.AppliedPartnerType, - partnerIds, - null, + input.PartnerIds, + input.CompanyIds, input.PartnerIds is not null || input.CompanyIds is not null); - partnerIds = partnerIdsForSave; + var partnerIds = partnerIdsForSave; var partnerContext = string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) ? partnerIds : null; - var (regionType, regionIdsForSave) = await AllScopeBindingHelper.NormalizeRegionScopeAsync( - db, - input.AppliedRegionType, - regionIds, - input.RegionIds is not null || input.GroupIds is not null, - partnerContext); - regionIds = regionIdsForSave; + string regionType; + List regionIds; + if (regionHasAll && concreteLocations.Count == 0) + { + regionType = ScopeAll; + regionIds = new List(); + } + else + { + var regionResult = await AllScopeBindingHelper.NormalizeRegionScopeAsync( + db, + input.AppliedRegionType, + LocationScopeBindingHelper.FilterConcreteScopeIds(mergedRegionIds), + hasRegionArray, + partnerContext); + regionType = regionResult.Type; + regionIds = regionResult.Ids; + } - var (locationType, locationIdsForSave) = await AllScopeBindingHelper.NormalizeLocationScopeAsync( - db, - input.AppliedLocationType, - locationIds, - input.LocationIds is not null || input.AppliedLocationIds is not null, - partnerContext, - string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) ? regionIds : null); - locationIds = locationIdsForSave; + string locationType; + List locationIds; + if (locationHasAll) + { + locationType = ScopeAll; + locationIds = new List(); + } + else + { + var locationResult = await AllScopeBindingHelper.NormalizeLocationScopeAsync( + db, + input.AppliedLocationType, + concreteLocations, + hasLocationArray, + partnerContext, + string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) ? regionIds : null); + locationType = locationResult.Type; + locationIds = locationResult.Ids; + } ValidateDimensionType("Company", partnerType); ValidateDimensionType("Region", regionType); @@ -134,7 +164,9 @@ public static class LabelTemplateScopeHelper || string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) || string.Equals(locationType, ScopeSpecified, StringComparison.OrdinalIgnoreCase); - if (anySpecified) + if (anySpecified + && (string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) + || string.Equals(locationType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))) { var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( db, @@ -300,6 +332,8 @@ public static class LabelTemplateScopeHelper .Where(x => templateIds.Contains(x.TemplateId)) .ToListAsync(); + var storedScopeTypes = await LabelTemplateScopeSchemaHelper.GetAppliedScopeTypesMapAsync(db, templateIds); + var partnerIdSet = partnerLinks.Select(x => x.PartnerId).Distinct(StringComparer.Ordinal).ToList(); var regionIdSet = regionLinks.Select(x => x.GroupId).Distinct(StringComparer.Ordinal).ToList(); var locationIdSet = locationLinks.Select(x => x.LocationId).Distinct(StringComparer.Ordinal).ToList(); @@ -360,9 +394,16 @@ public static class LabelTemplateScopeHelper var partnerType = pIds.Count > 0 ? ScopeSpecified : ScopeAll; var regionType = rIds.Count > 0 ? ScopeSpecified : ScopeAll; + if (storedScopeTypes.TryGetValue(template.Id, out var storedTypes)) + { + partnerType = NormalizeScopeType(storedTypes.PartnerType, partnerType); + regionType = NormalizeScopeType(storedTypes.RegionType, regionType); + } + if (hasExtendedScope && pIds.Count == 0 - && lIds.Count > 0) + && lIds.Count > 0 + && !string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)) { pIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, lIds); if (pIds.Count > 0) @@ -373,7 +414,8 @@ public static class LabelTemplateScopeHelper if (hasExtendedScope && rIds.Count == 0 - && lIds.Count > 0) + && lIds.Count > 0 + && !string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)) { rIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(db, lIds); if (rIds.Count > 0) @@ -401,6 +443,13 @@ public static class LabelTemplateScopeHelper ? AllLocationsDisplay : FormatLocationNames(lIds, locById); + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, + pIds, + rIds, + lIds, + ScopeAllEchoHelper.ForLabelTemplateDimensions(partnerType, regionType, locationType)); + result[template.Id] = new LabelTemplateScopeDisplay { Company = companyDisplay, @@ -409,9 +458,9 @@ public static class LabelTemplateScopeHelper AppliedPartnerType = partnerType, AppliedRegionType = regionType, AppliedLocationType = locationType, - PartnerIds = pIds, - RegionIds = rIds, - LocationIds = lIds + PartnerIds = collapsed.PartnerIds, + RegionIds = collapsed.RegionIds, + LocationIds = collapsed.LocationIds }; } @@ -419,31 +468,58 @@ public static class LabelTemplateScopeHelper } /// - /// 列表权限:按当前用户可见门店筛选模板(各维度 AND)。 - /// null 时不限制(管理员且未传 Query 筛选); - /// 非空时仅返回与可见 Company/Region/Location 匹配的模板,不包含未绑定任何范围的「全局」模板。 + /// 列表筛选:按当前用户可见范围筛选模板。 + /// 仅传 partnerId 时只按 Company 维度过滤(多公司绑定不会被门店 AND 误杀)。 /// public static async Task> ApplyTemplateScopeFilterAsync( ISqlSugarClient db, ISugarQueryable query, - List? scopedLocationIds) + List? scopedLocationIds, + string? partnerId = null, + string? groupId = null, + string? locationId = null) { + var hasExtendedScope = await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(db); + var schema = await LabelTemplateScopeSchemaHelper.GetStatusAsync(db); + + // 仅 PartnerId:只返回 fl_label_template_partner 含该公司的模板。 + // 不含 AppliedPartnerType=ALL(全公司模板在未传 PartnerId 时可见); + // 也不把「无关联行」当成 ALL,避免其他公司数据漏出。 + if (!LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter(partnerId, groupId, locationId) + && !string.IsNullOrWhiteSpace(partnerId)) + { + var pid = partnerId.Trim(); + if (!schema.HasPartnerScopeTable) + { + return query.Where(_ => false); + } + + return query.Where(t => + SqlFunc.Subqueryable() + .Where(p => p.TemplateId == t.Id && p.PartnerId == pid) + .Any()); + } + if (scopedLocationIds is null) { return query; } - var hasExtendedScope = await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(db); - if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + // 无可见门店:保留 Location=ALL(及无 location 关联)的模板 + return query.Where(t => + t.AppliedLocationType == ScopeAll + || !SqlFunc.Subqueryable() + .Where(l => l.TemplateId == t.Id) + .Any()); } if (!hasExtendedScope) { return query.Where(t => - SqlFunc.Subqueryable() + t.AppliedLocationType == ScopeAll + || SqlFunc.Subqueryable() .Where(l => l.TemplateId == t.Id && scopedLocationIds.Contains(l.LocationId)) .Any()); } @@ -453,6 +529,30 @@ public static class LabelTemplateScopeHelper var scopedGroupIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( db, scopedLocationIds); + // Company:AppliedPartnerType=ALL 或关联命中(不用「无行=ALL」) + if (schema.HasAppliedPartnerTypeColumn) + { + return query.Where( + """ + (AppliedPartnerType = @all + OR EXISTS (SELECT 1 FROM fl_label_template_partner p + WHERE p.TemplateId = fl_label_template.Id AND p.PartnerId IN (@partnerIds))) + AND (NOT EXISTS (SELECT 1 FROM fl_label_template_region r WHERE r.TemplateId = fl_label_template.Id) + OR EXISTS (SELECT 1 FROM fl_label_template_region r + WHERE r.TemplateId = fl_label_template.Id AND r.GroupId IN (@groupIds))) + AND (AppliedLocationType = @all + OR EXISTS (SELECT 1 FROM fl_label_template_location l + WHERE l.TemplateId = fl_label_template.Id AND l.LocationId IN (@locs))) + """, + new + { + all = ScopeAll, + partnerIds = scopedPartnerIds, + groupIds = scopedGroupIds.Count > 0 ? scopedGroupIds : new List { "__none__" }, + locs = scopedLocationIds + }); + } + return query.Where(t => SqlFunc.Subqueryable() .Where(p => p.TemplateId == t.Id && scopedPartnerIds.Contains(p.PartnerId)) @@ -567,15 +667,16 @@ public static class LabelTemplateScopeHelper private static async Task ValidateRegionIdsExistAsync(ISqlSugarClient db, List regionIds) { - if (regionIds.Count == 0) + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + if (ids.Count == 0) { return; } var count = await db.Queryable() - .Where(x => !x.IsDeleted && regionIds.Contains(x.Id)) + .Where(x => !x.IsDeleted && ids.Contains(x.Id)) .CountAsync(); - if (count != regionIds.Count) + if (count != ids.Count) { throw new UserFriendlyException("存在无效的 Region(regionIds/groupIds),请刷新后重试"); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeSchemaHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeSchemaHelper.cs index 2285075..56c211f 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeSchemaHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeSchemaHelper.cs @@ -315,6 +315,77 @@ public static class LabelTemplateScopeSchemaHelper return map.TryGetValue(templateId.Trim(), out var text) ? text : null; } + /// 批量读取模板 Company/Region 维度 ALL/SPECIFIED;列不存在时返回空字典。 + public static async Task> GetAppliedScopeTypesMapAsync( + ISqlSugarClient db, + IReadOnlyList templateIds) + { + var result = new Dictionary(StringComparer.Ordinal); + if (templateIds.Count == 0) + { + return result; + } + + var status = await GetStatusAsync(db); + if (!status.HasAppliedPartnerTypeColumn && !status.HasAppliedRegionTypeColumn) + { + return result; + } + + var ids = templateIds.Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (ids.Length == 0) + { + return result; + } + + var rows = await db.Ado.SqlQueryAsync( + """ + SELECT Id, AppliedPartnerType, AppliedRegionType + FROM fl_label_template + WHERE Id IN (@ids) + """, + new { ids }); + + foreach (var row in rows) + { + if (string.IsNullOrWhiteSpace(row.Id)) + { + continue; + } + + var partnerType = status.HasAppliedPartnerTypeColumn + ? NormalizeScopeTypeValue(row.AppliedPartnerType) + : ScopeAll; + var regionType = status.HasAppliedRegionTypeColumn + ? NormalizeScopeTypeValue(row.AppliedRegionType) + : ScopeAll; + result[row.Id.Trim()] = (partnerType, regionType); + } + + return result; + } + + private static string NormalizeScopeTypeValue(string? type) + { + var normalized = (type ?? ScopeAll).Trim().ToUpperInvariant(); + return normalized == ScopeSpecified ? ScopeSpecified : ScopeAll; + } + + private const string ScopeAll = "ALL"; + private const string ScopeSpecified = "SPECIFIED"; + + private sealed class AppliedScopeTypesRow + { + public string Id { get; init; } = string.Empty; + + public string? AppliedPartnerType { get; init; } + + public string? AppliedRegionType { get; init; } + } + /// 已迁移 Contents 列时写入(未迁移则 no-op)。 public static async Task SetContentsAsync(ISqlSugarClient db, string templateId, string? contents) { diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs index 7c8d213..ad1fdba 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs @@ -306,13 +306,14 @@ public static class LocationScopeBindingHelper merged.Add(id); } - var fromGroups = await ResolveLocationIdsFromGroupIdsAsync(db, groupIds); + var concreteGroupIds = FilterConcreteScopeIds(groupIds); + var fromGroups = await ResolveLocationIdsFromGroupIdsAsync(db, concreteGroupIds); foreach (var id in fromGroups) { merged.Add(id); } - foreach (var id in NormalizeIds(locationIds)) + foreach (var id in FilterConcreteScopeIds(locationIds)) { merged.Add(id); } @@ -321,6 +322,113 @@ public static class LocationScopeBindingHelper } /// + /// 多选 Id 是否含 ALL 哨兵(大小写不敏感)。与具体 Guid 同传时以 ALL 为准(全选)。 + /// + public static bool IsAllScopeSentinel(string? value) => + string.Equals(value?.Trim(), AllScopeBindingHelper.ScopeAll, StringComparison.OrdinalIgnoreCase); + + /// Id 列表是否含 ALL 哨兵。 + public static bool ContainsAllScopeSentinel(IReadOnlyList? ids) => + ids?.Any(IsAllScopeSentinel) == true; + + /// 去掉 ALL 哨兵,仅保留具体 Id。 + public static List FilterConcreteScopeIds(IReadOnlyList? ids) => + NormalizeIds(ids).Where(x => !IsAllScopeSentinel(x)).ToList(); + + /// + /// Team Member 门店范围落库(不做 partner+region+location 并集)。 + /// 优先级: + /// 1) locationIds 含 ALL → 公司全部门店; + /// 2) 有具体 locationIds,且 regionIds 为空或为 ALL → 只绑这些门店(UI 在 Region=ALL 下再收窄门店); + /// 3) regionIds 含 ALL → 公司全部 Region 下门店; + /// 4) 具体 regionIds → 按 Region 展开(忽略同传 locationIds,保证多区域能落库); + /// 5) 仅 → 公司全部门店。 + /// + public static async Task> ResolveTeamMemberLocationIdsForSaveAsync( + ISqlSugarClient db, + IReadOnlyList? partnerIds, + IReadOnlyList? regionIds, + IReadOnlyList? locationIds) + { + var normalizedPartners = NormalizeIds(partnerIds); + var locationHasAll = ContainsAllScopeSentinel(locationIds); + var concreteLocations = FilterConcreteScopeIds(locationIds); + var regionHasAll = ContainsAllScopeSentinel(regionIds); + var concreteRegions = FilterConcreteScopeIds(regionIds); + + // 1. locationIds 含 ALL:有具体 Region 时展开该 Region;否则按 Company 全部门店 + if (locationHasAll) + { + var expanded = await ExpandScopedAllLocationsForSaveAsync( + db, + normalizedPartners.Count > 0 ? normalizedPartners : null, + concreteRegions.Count > 0 ? concreteRegions : null); + if (expanded is not null) + { + return expanded; + } + + throw new UserFriendlyException("选择全部门店时需指定 Company(partnerId / partnerIds)或 Region"); + } + + // 2. 具体门店 +(无区域 / 区域为 ALL)→ 只绑这些门店,避免 regionIds=ALL 盖掉单店 + if (concreteLocations.Count > 0 && (regionHasAll || concreteRegions.Count == 0)) + { + return concreteLocations; + } + + // 3. regionIds 含 ALL(且无具体门店)→ 按 partner 展开全部 Region 下门店 + if (regionHasAll) + { + if (normalizedPartners.Count == 0) + { + throw new UserFriendlyException("选择全部 Region 时需指定 Company(partnerId / partnerIds)"); + } + + return await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, normalizedPartners, null); + } + + // 4. 具体 regionIds + if (concreteRegions.Count > 0) + { + var fromRegions = await ExpandScopedAllLocationsForSaveAsync( + db, + normalizedPartners.Count > 0 ? normalizedPartners : null, + concreteRegions); + var regionLocs = fromRegions ?? new List(); + + // 同传具体门店:若已覆盖该区全集(前端 Location=ALL 常展开 Guid)→ 用区内全部门店; + // 否则保留子集(Region 具体 + 部分门店) + if (concreteLocations.Count > 0) + { + if (regionLocs.Count > 0 + && AllScopeBindingHelper.IsFullIdSelection(concreteLocations, regionLocs)) + { + return regionLocs; + } + + return concreteLocations; + } + + return regionLocs; + } + + // 5. 仅有具体 locationIds → 只绑这些门店 + if (concreteLocations.Count > 0) + { + return concreteLocations; + } + + // 6. 仅有 partner → 绑该公司全部门店 + if (normalizedPartners.Count > 0) + { + return await MergeToLocationIdsAsync(db, normalizedPartners, null, null); + } + + return new List(); + } + + /// /// 标签类型/分类/多选项门店范围落库:仅按 Region/Location 合并,不因 Company(partner)展开并集。 /// public static async Task> ResolveEntityLocationIdsForSaveAsync( @@ -334,11 +442,28 @@ public static class LocationScopeBindingHelper return new List(); } + if (ContainsAllScopeSentinel(locationIds) + || (ContainsAllScopeSentinel(regionIds) + && FilterConcreteScopeIds(locationIds).Count == 0)) + { + throw new UserFriendlyException("门店范围含 ALL 哨兵时应归档为 ALL,不应进入 SPECIFIED 落库校验"); + } + + var concreteRegions = FilterConcreteScopeIds(regionIds); + var concreteLocations = FilterConcreteScopeIds(locationIds); + + // 有具体门店时以门店为准(不再与 Region 并集展开),避免「选 1 个 Location + 同传 Region」落成整 Region 再回显成 ALL + if (concreteLocations.Count > 0) + { + await ValidateLocationIdsExistAsync(db, concreteLocations); + return concreteLocations; + } + var merged = await MergeToLocationIdsAsync( db, (IReadOnlyList?)null, - regionIds, - locationIds); + concreteRegions, + concreteLocations); if (merged.Count == 0) { throw new UserFriendlyException("指定适用区域或门店时,至少需要匹配到一个有效门店"); @@ -349,6 +474,52 @@ public static class LocationScopeBindingHelper } /// + /// 将「范围内 ALL」展开为门店 Id:优先按具体 Region,其次按具体 Company;两者皆无则返回 null(调用方归档全局 ALL)。 + /// + public static async Task?> ExpandScopedAllLocationsForSaveAsync( + ISqlSugarClient db, + IReadOnlyList? partnerIds, + IReadOnlyList? regionIds) + { + var concreteRegions = FilterConcreteScopeIds(regionIds); + if (concreteRegions.Count > 0) + { + var fromRegions = await ResolveLocationIdsFromGroupIdsAsync(db, concreteRegions); + var partners = NormalizeIds(partnerIds); + if (partners.Count > 0) + { + var partnerLocSet = new HashSet( + await ResolveLocationIdsFromPartnerIdsAsync(db, partners), + StringComparer.OrdinalIgnoreCase); + fromRegions = fromRegions.Where(id => partnerLocSet.Contains(id)).ToList(); + } + + if (fromRegions.Count == 0) + { + throw new UserFriendlyException("指定 Region 下未匹配到有效门店"); + } + + await ValidateLocationIdsExistAsync(db, fromRegions); + return fromRegions; + } + + var concretePartners = NormalizeIds(partnerIds); + if (concretePartners.Count > 0) + { + var fromPartners = await ResolveLocationIdsFromPartnerIdsAsync(db, concretePartners); + if (fromPartners.Count == 0) + { + throw new UserFriendlyException("指定 Company 下未匹配到有效门店"); + } + + await ValidateLocationIdsExistAsync(db, fromPartners); + return fromPartners; + } + + return null; + } + + /// /// 根据已绑定门店反推适用的 Company Id(fl_partner.Id)。 /// public static async Task> ResolvePartnerIdsFromLocationIdsAsync( @@ -591,7 +762,7 @@ public static class LocationScopeBindingHelper /// public static async Task ValidateLocationIdsExistAsync(ISqlSugarClient db, IReadOnlyList locationIds) { - var ids = NormalizeIds(locationIds); + var ids = FilterConcreteScopeIds(locationIds); if (ids.Count == 0) { return; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/PlatformMenuHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/PlatformMenuHelper.cs new file mode 100644 index 0000000..5b80325 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/PlatformMenuHelper.cs @@ -0,0 +1,102 @@ +using FoodLabeling.Application.Services.DbModels; + +namespace FoodLabeling.Application.Helpers; + +/// +/// 平台端菜单判定(与 ThSaasMenuPermissionCatalog.IsPlatformOnlyMenu 一致,避免 Application 依赖 Th 程序集) +/// +public static class PlatformMenuHelper +{ + /// + /// 是否为仅平台端菜单(不可分配给公司) + /// + public static bool IsPlatformOnlyMenu(MenuDbEntity? menu) + { + if (menu == null) + { + return true; + } + + var code = menu.PermissionCode?.Trim() ?? string.Empty; + if (code.StartsWith("menu.platform", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var router = menu.Router?.Trim() ?? string.Empty; + return router.StartsWith("/platform", StringComparison.OrdinalIgnoreCase); + } + + /// + /// 从全量菜单中保留平台菜单及其祖先节点,以便组装菜单树 + /// + public static List FilterPlatformMenusWithAncestors(IReadOnlyList allMenus) + { + if (allMenus.Count == 0) + { + return new List(); + } + + var byId = allMenus + .GroupBy(m => m.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var keepIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var menu in allMenus.Where(IsPlatformOnlyMenu)) + { + IncludeWithAncestors(menu.Id, byId, keepIds); + } + + return allMenus.Where(m => keepIds.Contains(m.Id)).ToList(); + } + + private static void IncludeWithAncestors( + string menuId, + IReadOnlyDictionary byId, + HashSet keepIds) + { + if (!byId.TryGetValue(menuId, out var current)) + { + return; + } + + if (!keepIds.Add(current.Id)) + { + return; + } + + var parentId = current.ParentId?.Trim() ?? "0"; + if (parentId == "0" || parentId == Guid.Empty.ToString()) + { + return; + } + + IncludeWithAncestors(parentId, byId, keepIds); + } + + /// + /// 仅保留公司已开通菜单及其祖先节点(租户业务库 menu 表可能含 Seed 全量菜单)。 + /// + public static List FilterCompanyMenusWithAncestors( + IReadOnlyList allMenus, + IReadOnlySet enabledMenuIds) + { + if (allMenus.Count == 0 || enabledMenuIds.Count == 0) + { + return new List(); + } + + var byId = allMenus + .Where(m => !m.IsDeleted && !IsPlatformOnlyMenu(m)) + .GroupBy(m => m.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var keepIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var menuId in enabledMenuIds) + { + IncludeWithAncestors(menuId?.Trim() ?? string.Empty, byId, keepIds); + } + + return allMenus.Where(m => keepIds.Contains(m.Id)).ToList(); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs index d7a7fd1..0cbe0ca 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs @@ -67,6 +67,18 @@ public static class ReportsPrintLogExpiryHelper public static string ExtractFormattedExpiryText(string? printInputJson) => ExtractFormattedExpiryText(printInputJson, null, null, null); + /// 与 Print Log Expiration 同源:解析绝对过期时刻。 + public static bool TryResolveExpiryDateTime( + string? printInputJson, + string? renderTemplateJson, + DateTime? baseTime, + DateTime? printedAt, + out DateTime expiresAt) + { + var reference = baseTime ?? printedAt ?? DateTime.Now; + return TryResolveExpiryDateTime(printInputJson, renderTemplateJson, reference, out expiresAt); + } + private static bool TryResolveExpiryDateTime( string? printInputJson, string? renderTemplateJson, diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs index e856247..759880d 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs @@ -1,3 +1,4 @@ +using SqlSugar; using Volo.Abp.Users; using Yi.Framework.Rbac.Domain.Shared.Consts; @@ -66,4 +67,28 @@ public static class ReportsRoleHelper return false; } + + /// + /// App JWT 未写入角色声明时,按业务库 UserRole/Role 判断是否管理员。 + /// + public static async Task IsAdminRoleFromDbAsync(ISqlSugarClient db, Guid userId) + { + var rows = await db.Ado.SqlQueryAsync( + """ + SELECT r.RoleCode + FROM UserRole ur + INNER JOIN Role r ON ur.RoleId = r.Id + WHERE ur.UserId = @UserId AND r.IsDeleted = 0 AND r.State = 1 + """, + new { UserId = userId }); + + return rows.Any(r => + !string.IsNullOrWhiteSpace(r.RoleCode) && + string.Equals(r.RoleCode.Trim(), UserConst.AdminRolesCode, StringComparison.OrdinalIgnoreCase)); + } + + private sealed class AdminRoleCodeRow + { + public string? RoleCode { get; set; } + } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ScopeAllEchoHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ScopeAllEchoHelper.cs new file mode 100644 index 0000000..6987934 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ScopeAllEchoHelper.cs @@ -0,0 +1,210 @@ +using SqlSugar; + +namespace FoodLabeling.Application.Helpers; + +/// +/// 编辑/列表回显:库中可存展开 Guid;若绑定覆盖全集则将 Id 数组折叠为 ["ALL"] 哨兵(对齐 Team Member)。 +/// +public static class ScopeAllEchoHelper +{ + public sealed class ScopeAllEchoOptions + { + public bool IsPartnerAll { get; init; } + + public bool IsRegionAll { get; init; } + + public bool IsLocationAll { get; init; } + + /// 落库维度为 SPECIFIED 时禁止再按「恰好全选」折叠为 ALL 哨兵。 + public string? AppliedPartnerType { get; init; } + + public string? AppliedRegionType { get; init; } + + public string? AppliedLocationType { get; init; } + } + + /// + /// 将 partnerIds / regionIds / locationIds 折叠为 ALL 哨兵(编辑弹窗与列表 Id 数组回显)。 + /// + public static async Task<(List PartnerIds, List RegionIds, List LocationIds)> + CollapseScopeIdsToAllSentinelAsync( + ISqlSugarClient db, + IReadOnlyList? partnerIds, + IReadOnlyList? regionIds, + IReadOnlyList? locationIds, + ScopeAllEchoOptions? options = null) + { + options ??= new ScopeAllEchoOptions(); + var partners = LocationScopeBindingHelper.NormalizeIds(partnerIds); + var regions = LocationScopeBindingHelper.NormalizeIds(regionIds); + var locations = LocationScopeBindingHelper.NormalizeIds(locationIds); + + if (options.IsPartnerAll) + { + partners = new List { AllScopeBindingHelper.ScopeAll }; + } + else if (!AllScopeBindingHelper.IsDeclaredSpecified(options.AppliedPartnerType) && partners.Count > 0) + { + var allPartners = await AllScopeBindingHelper.ResolveAllPartnerIdsAsync(db); + if (allPartners.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(partners, allPartners)) + { + partners = new List { AllScopeBindingHelper.ScopeAll }; + } + } + + if (options.IsRegionAll) + { + regions = new List { AllScopeBindingHelper.ScopeAll }; + } + else if (!AllScopeBindingHelper.IsDeclaredSpecified(options.AppliedRegionType) && regions.Count > 0) + { + regions = await CollapseRegionsIfFullAsync(db, partners, regions); + } + + if (options.IsLocationAll) + { + locations = new List { AllScopeBindingHelper.ScopeAll }; + } + else if (!AllScopeBindingHelper.IsDeclaredSpecified(options.AppliedLocationType) && locations.Count > 0) + { + locations = await CollapseLocationsIfFullAsync(db, partners, regions, locations); + } + + return (partners, regions, locations); + } + + /// AvailabilityType=ALL 时 Region 与 Location 均为 ALL。 + public static ScopeAllEchoOptions ForAvailabilityAll(bool isAll) => + new() { IsRegionAll = isAll, IsLocationAll = isAll }; + + /// LabelTemplate 各维度独立 ALL 标记。 + public static ScopeAllEchoOptions ForLabelTemplateDimensions( + string? appliedPartnerType, + string? appliedRegionType, + string? appliedLocationType) => + new() + { + IsPartnerAll = AllScopeBindingHelper.IsDeclaredAll(appliedPartnerType), + IsRegionAll = AllScopeBindingHelper.IsDeclaredAll(appliedRegionType), + IsLocationAll = AllScopeBindingHelper.IsDeclaredAll(appliedLocationType), + AppliedPartnerType = appliedPartnerType, + AppliedRegionType = appliedRegionType, + AppliedLocationType = appliedLocationType + }; + + /// 标签类型/分类/多选项:Company + Region + Location 可用范围。 + public static ScopeAllEchoOptions ForLabelEntityScope( + string? appliedPartnerType, + string? appliedRegionType, + string? availabilityType) => + new() + { + IsPartnerAll = AllScopeBindingHelper.IsDeclaredAll(appliedPartnerType), + IsRegionAll = AllScopeBindingHelper.IsDeclaredAll(appliedRegionType), + IsLocationAll = AllScopeBindingHelper.IsDeclaredAll(availabilityType), + AppliedPartnerType = appliedPartnerType, + AppliedRegionType = appliedRegionType, + AppliedLocationType = availabilityType + }; + + /// 兼容旧调用:无 Region 类型时用 AvailabilityType 同时驱动 Region/Location。 + public static ScopeAllEchoOptions ForLabelEntityScope( + string? appliedPartnerType, + string? availabilityType) => + ForLabelEntityScope(appliedPartnerType, availabilityType, availabilityType); + + /// Label:AppliedRegionType=ALL 且未落门店快照时 Region/Location 均为 ALL。 + public static ScopeAllEchoOptions ForLabelRegionScope( + string? appliedRegionType, + IReadOnlyList locationIds) + { + var isRegionAll = AllScopeBindingHelper.IsDeclaredAll(appliedRegionType); + var normalizedLocations = LocationScopeBindingHelper.NormalizeIds(locationIds); + var isLocationAll = isRegionAll && normalizedLocations.Count == 0; + return new ScopeAllEchoOptions + { + IsRegionAll = isRegionAll, + IsLocationAll = isLocationAll, + AppliedRegionType = appliedRegionType, + AppliedLocationType = isLocationAll + ? AllScopeBindingHelper.ScopeAll + : AllScopeBindingHelper.ScopeSpecified + }; + } + + private static async Task> CollapseRegionsIfFullAsync( + ISqlSugarClient db, + IReadOnlyList partners, + IReadOnlyList regions) + { + var partnerContext = ResolvePartnerContextForCollapse(partners); + var allRegionIds = partnerContext is { Count: > 0 } + ? await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(db, partnerContext) + : await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, null); + + if (allRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(regions, allRegionIds)) + { + return new List { AllScopeBindingHelper.ScopeAll }; + } + + return regions.ToList(); + } + + private static async Task> CollapseLocationsIfFullAsync( + ISqlSugarClient db, + IReadOnlyList partners, + IReadOnlyList regions, + IReadOnlyList locations) + { + var partnerContext = ResolvePartnerContextForCollapse(partners); + List allLocationIds; + + if (regions.Count == 1 && AllScopeBindingHelper.IsDeclaredAll(regions[0])) + { + allLocationIds = partnerContext is { Count: > 0 } + ? await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerContext) + : await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, null, null); + } + else if (regions.Count > 0 && !regions.Any(r => AllScopeBindingHelper.IsDeclaredAll(r))) + { + allLocationIds = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partnerContext, regions); + } + else if (partnerContext is { Count: > 0 }) + { + allLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerContext); + } + else + { + allLocationIds = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, null, null); + } + + if (allLocationIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(locations, allLocationIds)) + { + // 具体 Region:覆盖该区全部门店时一律回显 ["ALL"](含区内仅 1 店;前端 ALL 常展开为具体 Guid) + var hasConcreteRegions = regions.Count > 0 + && !regions.Any(AllScopeBindingHelper.IsDeclaredAll); + if (hasConcreteRegions) + { + return new List { AllScopeBindingHelper.ScopeAll }; + } + + // Region=ALL(或无具体 Region)时:仅多店才折叠,避免「Region=ALL + 选 1 店」误成 Location ALL + if (locations.Count > 1 || allLocationIds.Count > 1) + { + return new List { AllScopeBindingHelper.ScopeAll }; + } + } + + return locations.ToList(); + } + + private static List? ResolvePartnerContextForCollapse(IReadOnlyList partners) + { + if (partners.Count == 0 || AllScopeBindingHelper.IsDeclaredAll(partners[0])) + { + return null; + } + + return partners.ToList(); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberScopeDisplayHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberScopeDisplayHelper.cs index cec6458..450386b 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberScopeDisplayHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberScopeDisplayHelper.cs @@ -26,7 +26,8 @@ public static class TeamMemberScopeDisplayHelper Guid? roleId, IReadOnlyList partnerIds, IReadOnlyList regionIds, - IReadOnlyList assigned) + IReadOnlyList assigned, + string? appliedLocationType = null) { var assignedIds = assigned .Select(x => x.Id) @@ -35,30 +36,43 @@ public static class TeamMemberScopeDisplayHelper .Distinct(StringComparer.Ordinal) .ToList(); + if (AllScopeBindingHelper.IsDeclaredAll(appliedLocationType)) + { + if (partnerIds.Count > 0) + { + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + var universe = concreteRegions.Count > 0 + ? await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partnerIds, concreteRegions) + : await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerIds); + if (universe.Count > 0 + && !AllScopeBindingHelper.IsFullIdSelection(assignedIds, universe)) + { + return assigned.ToList(); + } + } + + return AllLocationDisplay; + } + if (assignedIds.Count == 0) { return assigned.ToList(); } - if (await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(db, roleId) && partnerIds.Count > 0) + if (partnerIds.Count > 0) { - var allPartnerLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync( - db, partnerIds); - if (allPartnerLocationIds.Count > 0 && - AllScopeBindingHelper.IsFullIdSelection(assignedIds, allPartnerLocationIds)) + // 有具体 Region 时按 Region 范围内全选判断;否则按 Company 全部门店 + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + var universe = concreteRegions.Count > 0 + ? await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partnerIds, concreteRegions) + : await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerIds); + if (universe.Count > 0 && + AllScopeBindingHelper.IsFullIdSelection(assignedIds, universe)) { return AllLocationDisplay; } } - var universeLocationIds = await LocationScopeBindingHelper.MergeToLocationIdsAsync( - db, partnerIds, regionIds, null); - if (universeLocationIds.Count > 0 && - AllScopeBindingHelper.IsFullIdSelection(assignedIds, universeLocationIds)) - { - return AllLocationDisplay; - } - return assigned.ToList(); } @@ -69,8 +83,15 @@ public static class TeamMemberScopeDisplayHelper ISqlSugarClient db, IReadOnlyList partnerIds, IReadOnlyList regionIds, - IReadOnlyDictionary regionNameMap) + IReadOnlyDictionary regionNameMap, + string? appliedRegionType = null) { + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType) + || (regionIds.Count == 1 && AllScopeBindingHelper.IsDeclaredAll(regionIds[0]))) + { + return FoodLabelingDisplayConsts.AllRegion; + } + if (regionIds.Count == 0) { return FoodLabelingDisplayConsts.NotAvailable; @@ -92,6 +113,118 @@ public static class TeamMemberScopeDisplayHelper : id)); } + /// + /// 编辑回显:库中存展开后的 Guid;结合 AppliedRegionType/AppliedLocationType 折叠为 ["ALL"]。 + /// + public static async Task<(List RegionIds, List LocationIds, List Assigned)> + CollapseScopeIdsToAllSentinelForEditAsync( + ISqlSugarClient db, + IReadOnlyList partnerIds, + IReadOnlyList regionIds, + IReadOnlyList locationIds, + IReadOnlyList assigned, + string? appliedRegionType = null, + string? appliedLocationType = null) + { + var partners = LocationScopeBindingHelper.NormalizeIds(partnerIds); + var regions = LocationScopeBindingHelper.NormalizeIds(regionIds); + var locations = LocationScopeBindingHelper.NormalizeIds(locationIds); + var assignedList = assigned?.ToList() ?? new List(); + + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType)) + { + regions = new List { AllScopeBindingHelper.ScopeAll }; + } + + if (AllScopeBindingHelper.IsDeclaredAll(appliedLocationType)) + { + // 误标 ALL 但库中仅为部分门店时,回显具体 Id(与新增传参对称) + if (locations.Count > 0 && partners.Count > 0) + { + var scopedRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regions); + var universe = scopedRegions.Count > 0 + ? await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partners, scopedRegions) + : await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners); + if (!AllScopeBindingHelper.IsFullIdSelection(locations, universe)) + { + return (regions, locations, assignedList); + } + } + + locations = new List { AllScopeBindingHelper.ScopeAll }; + assignedList = AllLocationDisplay; + return (regions, locations, assignedList); + } + + // 落库为 SPECIFIED(显式选店):禁止再按「区内恰好全选」折叠成 ALL + if (AllScopeBindingHelper.IsDeclaredSpecified(appliedLocationType)) + { + if (partners.Count == 0) + { + return (regions, locations, assignedList); + } + + if (!AllScopeBindingHelper.IsDeclaredAll(appliedRegionType)) + { + var allRegionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(db, partners); + if (allRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(regions, allRegionIds)) + { + regions = new List { AllScopeBindingHelper.ScopeAll }; + } + } + + return (regions, locations, assignedList); + } + + if (partners.Count == 0) + { + return (regions, locations, assignedList); + } + + if (!AllScopeBindingHelper.IsDeclaredAll(appliedRegionType)) + { + var allRegionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(db, partners); + if (allRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(regions, allRegionIds)) + { + regions = new List { AllScopeBindingHelper.ScopeAll }; + } + } + + // Region=ALL + 具体门店:禁止再按「派生单 Region 区内全选」把 Location 折成 ALL + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType) + || (regions.Count == 1 && AllScopeBindingHelper.IsDeclaredAll(regions[0]))) + { + var partnerUniverse = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync( + db, partners); + if (partnerUniverse.Count > 1 + && locations.Count > 1 + && AllScopeBindingHelper.IsFullIdSelection(locations, partnerUniverse)) + { + locations = new List { AllScopeBindingHelper.ScopeAll }; + assignedList = AllLocationDisplay; + } + + return (regions, locations, assignedList); + } + + // 具体 Region:按区内门店全集判断是否折叠 locationIds + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regions); + var allLocationIds = concreteRegions.Count > 0 + ? await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partners, concreteRegions) + : await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners); + if (allLocationIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(locations, allLocationIds)) + { + var hasConcreteRegions = concreteRegions.Count > 0; + if (hasConcreteRegions || locations.Count > 1 || allLocationIds.Count > 1) + { + locations = new List { AllScopeBindingHelper.ScopeAll }; + assignedList = AllLocationDisplay; + } + } + + return (regions, locations, assignedList); + } + public static string FormatLocationTextForList(IReadOnlyList assigned) { if (assigned.Count == 0) diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantBusinessContextHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantBusinessContextHelper.cs new file mode 100644 index 0000000..1e0bc88 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantBusinessContextHelper.cs @@ -0,0 +1,140 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; +using Yi.Framework.Rbac.Domain.Shared.Consts; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Application.Helpers; + +/// +/// SaaS 业务租户上下文判定(平台主库 / Default 租户不走公司菜单子集过滤)。 +/// +public static class TenantBusinessContextHelper +{ + private static readonly Guid ProtectedDefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + /// + /// 解析当前请求的业务租户 Id:优先 ,其次 JWT / Authorization Claim。 + /// + public static Guid? ResolveBusinessTenantId(ICurrentTenant? currentTenant, HttpContext? httpContext) + { + if (currentTenant?.Id is { } fromContext + && fromContext != Guid.Empty + && fromContext != ProtectedDefaultTenantId) + { + return fromContext; + } + + var fromJwt = TryGetTenantIdFromHttpContext(httpContext); + if (fromJwt.HasValue + && fromJwt.Value != Guid.Empty + && fromJwt.Value != ProtectedDefaultTenantId) + { + return fromJwt; + } + + return null; + } + + /// + /// 当前请求是否处于真实公司业务租户上下文(须按 fl_th_tenant_menu_permission 限定可分配菜单)。 + /// + public static bool ShouldScopeMenusByCompany( + DbConnOptions options, + ICurrentTenant? currentTenant, + HttpContext? httpContext) + { + return ShouldScopeMenusByCompany(options, ResolveBusinessTenantId(currentTenant, httpContext)); + } + + /// + /// 当前请求是否处于真实公司业务租户上下文(须按 fl_th_tenant_menu_permission 限定可分配菜单)。 + /// + public static bool ShouldScopeMenusByCompany(DbConnOptions options, Guid? tenantId) + { + return options.EnabledSaasMultiTenancy + && tenantId.HasValue + && tenantId.Value != Guid.Empty + && tenantId.Value != ProtectedDefaultTenantId; + } + + /// + /// 从 HttpContext Principal 或 Authorization Bearer JWT 读取 TenantId Claim 原始值。 + /// + public static string? TryGetTenantIdClaimValue(HttpContext? httpContext) + { + if (httpContext is null) + { + return null; + } + + return TryGetTenantIdFromPrincipal(httpContext.User) + ?? TryGetTenantIdFromAuthorizationHeader(httpContext.Request.Headers.Authorization.ToString()); + } + + private static Guid? TryGetTenantIdFromHttpContext(HttpContext? httpContext) + { + var tenantClaim = TryGetTenantIdClaimValue(httpContext); + if (string.IsNullOrWhiteSpace(tenantClaim) + || !Guid.TryParse(tenantClaim, out var tenantGuid) + || tenantGuid == Guid.Empty) + { + return null; + } + + return tenantGuid; + } + + private static string? TryGetTenantIdFromPrincipal(ClaimsPrincipal? user) + { + if (user?.Identity?.IsAuthenticated != true) + { + return null; + } + + return user.FindFirst(TokenTypeConst.TenantId)?.Value + ?? user.FindFirst(AbpClaimTypes.TenantId)?.Value + ?? user.Claims.FirstOrDefault(c => + c.Type.EndsWith("tenantId", StringComparison.OrdinalIgnoreCase) + || c.Type.Equals("tenantid", StringComparison.OrdinalIgnoreCase))?.Value; + } + + /// + /// 多租户中间件可能早于 JWT Principal 就绪;直接从 Authorization 解析 TenantId Claim。 + /// + private static string? TryGetTenantIdFromAuthorizationHeader(string? authorization) + { + if (string.IsNullOrWhiteSpace(authorization)) + { + return null; + } + + const string bearerPrefix = "Bearer "; + if (!authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var jwt = authorization[bearerPrefix.Length..].Trim(); + if (string.IsNullOrWhiteSpace(jwt)) + { + return null; + } + + try + { + var token = new JwtSecurityTokenHandler().ReadJwtToken(jwt); + return token.Claims.FirstOrDefault(c => + c.Type == TokenTypeConst.TenantId + || c.Type == AbpClaimTypes.TenantId + || c.Type.EndsWith("tenantId", StringComparison.OrdinalIgnoreCase)) + ?.Value; + } + catch (Exception) + { + return null; + } + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs index 38438c3..9c748e4 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs @@ -1,5 +1,6 @@ using Volo.Abp; using Volo.Abp.MultiTenancy; +using Yi.Framework.SqlSugarCore.Abstractions; namespace FoodLabeling.Application.Helpers; @@ -8,9 +9,15 @@ namespace FoodLabeling.Application.Helpers; /// public static class TenantContextGuard { + /// + /// 平台主库登录(JWT/__tenant 均无业务租户)时访问 fl_* 等业务表的友好提示。 + /// + public const string PlatformCannotAccessBusinessDataMessage = + "当前为平台主库登录,无法访问公司业务数据(标签/产品/成员等)。请选择具体公司登录,或使用平台「公司管理」相关接口。"; + public static void EnsureTenantResolved(ICurrentTenant currentTenant, string? operation = null) { - if (currentTenant.Id.HasValue) + if (currentTenant.Id.HasValue && currentTenant.Id.Value != Guid.Empty) { return; } @@ -19,6 +26,78 @@ public static class TenantContextGuard ? "未识别租户上下文" : $"{operation}:未识别租户上下文"; throw new UserFriendlyException( - $"{hint}。请使用泰额登录接口(th-web-auth / th-app-auth)或请求头 __tenant 携带租户 Id。"); + $"{hint}。请使用泰额登录接口(th-web-auth / th-app-auth)选择具体公司登录,或请求头 __tenant 携带租户 Id。"); + } + + /// + /// 泰额 SaaS 多租户开启时,业务表(fl_* / location 等)必须走租户库,禁止落到 host。 + /// + public static void EnsureBusinessTenantIfSaas( + ICurrentTenant currentTenant, + DbConnOptions? dbConnOptions, + string? operation = null) + { + if (dbConnOptions is null || !dbConnOptions.EnabledSaasMultiTenancy) + { + return; + } + + EnsureTenantResolved(currentTenant, operation); + } + + /// + /// 判断当前 DbContext 是否连到平台主库(antis-foodlabeling-host)。 + /// + public static bool IsConnectedToHostDatabase(ISqlSugarDbContext dbContext, DbConnOptions dbConnOptions) + { + var dbName = dbContext.SqlSugarClient.Ado.Connection.Database; + var hostDbName = TryExtractDatabaseName(dbConnOptions.Url); + return !string.IsNullOrWhiteSpace(hostDbName) + && string.Equals(dbName, hostDbName, StringComparison.OrdinalIgnoreCase); + } + + /// + /// SaaS 模式下校验 DbContext 未落到 host 主库(双保险,避免缺表 500)。 + /// + public static void EnsureNotHostDatabaseIfSaas( + ISqlSugarDbContext dbContext, + DbConnOptions dbConnOptions, + string? operation = null) + { + if (!dbConnOptions.EnabledSaasMultiTenancy) + { + return; + } + + if (!IsConnectedToHostDatabase(dbContext, dbConnOptions)) + { + return; + } + + var hint = string.IsNullOrWhiteSpace(operation) + ? PlatformCannotAccessBusinessDataMessage + : $"{operation}:{PlatformCannotAccessBusinessDataMessage}"; + throw new UserFriendlyException(hint); + } + + private static string? TryExtractDatabaseName(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + return null; + } + + foreach (var part in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = part.Split('=', 2, StringSplitOptions.TrimEntries); + if (kv.Length == 2 + && (kv[0].Equals("database", StringComparison.OrdinalIgnoreCase) + || kv[0].Equals("Database", StringComparison.OrdinalIgnoreCase))) + { + return kv[1].Trim(); + } + } + + return null; } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TrainingFileScopeHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TrainingFileScopeHelper.cs new file mode 100644 index 0000000..543fa71 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TrainingFileScopeHelper.cs @@ -0,0 +1,467 @@ +using FoodLabeling.Application.Services.DbModels; +using FoodLabeling.Domain.Shared.Helpers; +using SqlSugar; +using Volo.Abp; + +namespace FoodLabeling.Application.Helpers; + +/// +/// 培训文件适用范围:Company / Region / Location 三维度独立 ALL/SPECIFIED。 +/// +public static class TrainingFileScopeHelper +{ + public sealed class TrainingFileScopeSaveResult + { + public string AppliedPartnerType { get; init; } = AllScopeBindingHelper.ScopeAll; + + public List PartnerIds { get; init; } = new(); + + public string AppliedRegionType { get; init; } = AllScopeBindingHelper.ScopeAll; + + public List RegionIds { get; init; } = new(); + + public string AvailabilityType { get; init; } = AllScopeBindingHelper.ScopeAll; + + public List LocationIds { get; init; } = new(); + } + + public sealed class TrainingFileScopeDisplay + { + public string AppliedPartnerType { get; init; } = AllScopeBindingHelper.ScopeAll; + + public string Company { get; init; } = LabelEntityPartnerScopeHelper.AllCompaniesDisplay; + + public List PartnerIds { get; init; } = new(); + + public string AppliedRegionType { get; init; } = AllScopeBindingHelper.ScopeAll; + + public string Region { get; init; } = FoodLabelingDisplayConsts.AllRegion; + + public List RegionIds { get; init; } = new(); + + public string AvailabilityType { get; init; } = AllScopeBindingHelper.ScopeAll; + + public string Location { get; init; } = FoodLabelingDisplayConsts.AllLocation; + + public List LocationIds { get; init; } = new(); + } + + public sealed class LocationScopeContext + { + public string LocationId { get; init; } = string.Empty; + + public string? PartnerId { get; init; } + + public string? GroupId { get; init; } + } + + /// + /// 解析保存入参中的三维度范围。 + /// + public static async Task ResolveScopeForSaveAsync( + ISqlSugarClient db, + string? appliedPartnerType, + IReadOnlyList? partnerIds, + IReadOnlyList? companyIds, + string? appliedRegionType, + IReadOnlyList? regionIds, + IReadOnlyList? groupIds, + string? availabilityType, + IReadOnlyList? locationIds) + { + var partnerScope = await LabelEntityPartnerScopeHelper.ResolvePartnerScopeForSaveAsync( + db, + appliedPartnerType, + partnerIds, + companyIds); + + var partnerContext = string.Equals( + partnerScope.AppliedPartnerType, + AllScopeBindingHelper.ScopeSpecified, + StringComparison.OrdinalIgnoreCase) + ? partnerScope.PartnerIds + : null; + + var mergedRegionIds = NormalizeRegionIds(regionIds, groupIds); + var hasRegionArray = regionIds is not null || groupIds is not null; + var (regionType, regionIdsForSave) = await AllScopeBindingHelper.NormalizeRegionScopeAsync( + db, + appliedRegionType, + mergedRegionIds, + hasRegionArray, + partnerContext); + + if (string.Equals(regionType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase) + && regionIdsForSave.Count > 0) + { + await ValidateGroupIdsExistAsync(db, regionIdsForSave); + } + + var hasLocationArray = locationIds is not null; + var (locationType, locationIdsForSave) = await AllScopeBindingHelper.NormalizeLocationScopeAsync( + db, + availabilityType, + locationIds ?? new List(), + hasLocationArray, + partnerContext, + regionIdsForSave); + + if (string.Equals(locationType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase) + && locationIdsForSave.Count > 0) + { + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, locationIdsForSave); + } + + return new TrainingFileScopeSaveResult + { + AppliedPartnerType = partnerScope.AppliedPartnerType, + PartnerIds = partnerScope.PartnerIds, + AppliedRegionType = regionType, + RegionIds = regionIdsForSave, + AvailabilityType = locationType, + LocationIds = locationIdsForSave + }; + } + + /// + /// 保存培训文件三维度范围关联。 + /// + public static async Task SaveScopeAsync( + ISqlSugarClient db, + string trainingFileId, + TrainingFileScopeSaveResult scope, + string? currentUserId, + DateTime now) + { + await db.Deleteable() + .Where(x => x.TrainingFileId == trainingFileId) + .ExecuteCommandAsync(); + await db.Deleteable() + .Where(x => x.TrainingFileId == trainingFileId) + .ExecuteCommandAsync(); + await db.Deleteable() + .Where(x => x.TrainingFileId == trainingFileId) + .ExecuteCommandAsync(); + + if (string.Equals(scope.AppliedPartnerType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase) + && scope.PartnerIds.Count > 0) + { + var rows = scope.PartnerIds.Select(pid => new FlTrainingFilePartnerDbEntity + { + Id = YitIdHelper.NextId().ToString(), + TrainingFileId = trainingFileId, + PartnerId = pid, + CreationTime = now, + CreatorId = currentUserId + }).ToList(); + await db.Insertable(rows).ExecuteCommandAsync(); + } + + if (string.Equals(scope.AppliedRegionType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase) + && scope.RegionIds.Count > 0) + { + var rows = scope.RegionIds.Select(gid => new FlTrainingFileRegionDbEntity + { + Id = YitIdHelper.NextId().ToString(), + TrainingFileId = trainingFileId, + GroupId = gid, + CreationTime = now, + CreatorId = currentUserId + }).ToList(); + await db.Insertable(rows).ExecuteCommandAsync(); + } + + if (string.Equals(scope.AvailabilityType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase) + && scope.LocationIds.Count > 0) + { + var rows = scope.LocationIds.Select(lid => new FlTrainingFileLocationDbEntity + { + Id = YitIdHelper.NextId().ToString(), + TrainingFileId = trainingFileId, + LocationId = lid, + CreationTime = now, + CreatorId = currentUserId + }).ToList(); + await db.Insertable(rows).ExecuteCommandAsync(); + } + } + + /// + /// 构建多个培训文件的适用范围回显(列表/树批量用)。 + /// + public static async Task> BuildScopeDisplayMapAsync( + ISqlSugarClient db, + IReadOnlyList files) + { + if (files.Count == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + var tasks = files.Select(async file => + (file.Id, Display: await BuildScopeDisplayAsync(db, file))); + var results = await Task.WhenAll(tasks); + return results.ToDictionary(x => x.Id, x => x.Display, StringComparer.Ordinal); + } + + /// + /// 删除培训文件全部范围关联。 + /// + public static async Task DeleteScopeRowsAsync(ISqlSugarClient db, string trainingFileId) + { + await db.Deleteable() + .Where(x => x.TrainingFileId == trainingFileId) + .ExecuteCommandAsync(); + await db.Deleteable() + .Where(x => x.TrainingFileId == trainingFileId) + .ExecuteCommandAsync(); + await db.Deleteable() + .Where(x => x.TrainingFileId == trainingFileId) + .ExecuteCommandAsync(); + } + + /// + /// 构建详情/编辑回显用的范围数据。 + /// + public static async Task BuildScopeDisplayAsync( + ISqlSugarClient db, + FlTrainingFileDbEntity file) + { + var partnerIds = string.Equals( + file.AppliedPartnerType, + AllScopeBindingHelper.ScopeSpecified, + StringComparison.OrdinalIgnoreCase) + ? await db.Queryable() + .Where(x => x.TrainingFileId == file.Id) + .Select(x => x.PartnerId) + .ToListAsync() + : new List(); + + var regionIds = string.Equals( + file.AppliedRegionType, + AllScopeBindingHelper.ScopeSpecified, + StringComparison.OrdinalIgnoreCase) + ? await db.Queryable() + .Where(x => x.TrainingFileId == file.Id) + .Select(x => x.GroupId) + .ToListAsync() + : new List(); + + var locationIds = string.Equals( + file.AvailabilityType, + AllScopeBindingHelper.ScopeSpecified, + StringComparison.OrdinalIgnoreCase) + ? await db.Queryable() + .Where(x => x.TrainingFileId == file.Id) + .Select(x => x.LocationId) + .ToListAsync() + : new List(); + + partnerIds = LocationScopeBindingHelper.NormalizeIds(partnerIds); + regionIds = LocationScopeBindingHelper.NormalizeIds(regionIds); + locationIds = LocationScopeBindingHelper.NormalizeIds(locationIds); + + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, + partnerIds, + regionIds, + locationIds, + ScopeAllEchoHelper.ForLabelTemplateDimensions( + file.AppliedPartnerType, + file.AppliedRegionType, + file.AvailabilityType)); + + return new TrainingFileScopeDisplay + { + AppliedPartnerType = file.AppliedPartnerType, + Company = await BuildPartnerDisplayAsync(db, file.AppliedPartnerType, collapsed.PartnerIds), + PartnerIds = collapsed.PartnerIds, + AppliedRegionType = file.AppliedRegionType, + Region = await BuildRegionDisplayAsync(db, file.AppliedRegionType, collapsed.RegionIds), + RegionIds = collapsed.RegionIds, + AvailabilityType = file.AvailabilityType, + Location = await BuildLocationDisplayAsync(db, file.AvailabilityType, collapsed.LocationIds), + LocationIds = collapsed.LocationIds + }; + } + + /// + /// 按门店上下文过滤可见文件(Company + Region + Location 三维度 AND)。 + /// + public static ISugarQueryable ApplyLocationVisibilityFilter( + ISugarQueryable query, + LocationScopeContext? context) + { + if (context is null || string.IsNullOrWhiteSpace(context.LocationId)) + { + return query; + } + + var locationId = context.LocationId.Trim(); + var partnerId = context.PartnerId?.Trim(); + var groupId = context.GroupId?.Trim(); + + return query.Where(f => + f.AppliedPartnerType == AllScopeBindingHelper.ScopeAll + || (partnerId != null + && SqlFunc.Subqueryable() + .Where(p => p.TrainingFileId == f.Id && p.PartnerId == partnerId) + .Any())) + .Where(f => + f.AppliedRegionType == AllScopeBindingHelper.ScopeAll + || (groupId != null + && SqlFunc.Subqueryable() + .Where(r => r.TrainingFileId == f.Id && r.GroupId == groupId) + .Any())) + .Where(f => + f.AvailabilityType == AllScopeBindingHelper.ScopeAll + || SqlFunc.Subqueryable() + .Where(l => l.TrainingFileId == f.Id && l.LocationId == locationId) + .Any()); + } + + /// + /// 解析门店对应的 Company / Region 上下文。 + /// + public static async Task ResolveLocationScopeContextAsync( + ISqlSugarClient db, + string locationId) + { + if (string.IsNullOrWhiteSpace(locationId)) + { + return null; + } + + var normalized = locationId.Trim(); + var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, new[] { normalized }); + var groupIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(db, new[] { normalized }); + + return new LocationScopeContext + { + LocationId = normalized, + PartnerId = partnerIds.FirstOrDefault(), + GroupId = groupIds.FirstOrDefault() + }; + } + + private static List NormalizeRegionIds( + IReadOnlyList? regionIds, + IReadOnlyList? groupIds) + { + var merged = new HashSet(StringComparer.Ordinal); + foreach (var id in LocationScopeBindingHelper.NormalizeIds(regionIds)) + { + merged.Add(id); + } + + foreach (var id in LocationScopeBindingHelper.NormalizeIds(groupIds)) + { + merged.Add(id); + } + + return merged.OrderBy(x => x, StringComparer.Ordinal).ToList(); + } + + private static async Task ValidateGroupIdsExistAsync(ISqlSugarClient db, List groupIds) + { + if (groupIds.Count == 0) + { + return; + } + + var existing = await db.Queryable() + .Where(x => !x.IsDeleted && groupIds.Contains(x.Id)) + .Select(x => x.Id) + .ToListAsync(); + var existingSet = new HashSet(existing, StringComparer.OrdinalIgnoreCase); + var missing = groupIds.Where(id => !existingSet.Contains(id)).ToList(); + if (missing.Count > 0) + { + throw new UserFriendlyException("存在无效的 Region Id"); + } + } + + private static async Task BuildPartnerDisplayAsync( + ISqlSugarClient db, + string appliedPartnerType, + IReadOnlyList partnerIds) + { + if (!string.Equals(appliedPartnerType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase)) + { + return LabelEntityPartnerScopeHelper.AllCompaniesDisplay; + } + + if (LocationScopeBindingHelper.ContainsAllScopeSentinel(partnerIds)) + { + return LabelEntityPartnerScopeHelper.AllCompaniesDisplay; + } + + if (partnerIds.Count == 0) + { + return FoodLabelingDisplayConsts.NotAvailable; + } + + var rows = await db.Queryable() + .Where(x => !x.IsDeleted && partnerIds.Contains(x.Id)) + .Select(x => x.PartnerName) + .ToListAsync(); + var names = rows.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()).Distinct().OrderBy(x => x).ToList(); + return names.Count > 0 ? string.Join(", ", names) : FoodLabelingDisplayConsts.NotAvailable; + } + + private static async Task BuildRegionDisplayAsync( + ISqlSugarClient db, + string appliedRegionType, + IReadOnlyList regionIds) + { + if (!string.Equals(appliedRegionType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase)) + { + return FoodLabelingDisplayConsts.AllRegion; + } + + if (LocationScopeBindingHelper.ContainsAllScopeSentinel(regionIds) || regionIds.Count == 0) + { + return regionIds.Count == 0 + ? FoodLabelingDisplayConsts.NotAvailable + : FoodLabelingDisplayConsts.AllRegion; + } + + var rows = await db.Queryable() + .Where(x => !x.IsDeleted && regionIds.Contains(x.Id)) + .Select(x => x.GroupName) + .ToListAsync(); + var names = rows.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()).Distinct().OrderBy(x => x).ToList(); + return names.Count > 0 ? string.Join(", ", names) : FoodLabelingDisplayConsts.NotAvailable; + } + + private static async Task BuildLocationDisplayAsync( + ISqlSugarClient db, + string availabilityType, + IReadOnlyList locationIds) + { + if (!string.Equals(availabilityType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase)) + { + return FoodLabelingDisplayConsts.AllLocation; + } + + if (LocationScopeBindingHelper.ContainsAllScopeSentinel(locationIds) || locationIds.Count == 0) + { + return locationIds.Count == 0 + ? FoodLabelingDisplayConsts.NotAvailable + : FoodLabelingDisplayConsts.AllLocation; + } + + var guidList = locationIds.Where(x => Guid.TryParse(x, out _)).Select(Guid.Parse).ToList(); + if (guidList.Count == 0) + { + return FoodLabelingDisplayConsts.NotAvailable; + } + + var rows = await db.Queryable() + .Where(x => !x.IsDeleted && guidList.Contains(x.Id)) + .Select(x => x.LocationName) + .ToListAsync(); + var names = rows.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()).Distinct().OrderBy(x => x).ToList(); + return names.Count > 0 ? string.Join(", ", names) : FoodLabelingDisplayConsts.NotAvailable; + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/UsAppAuthScopeHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/UsAppAuthScopeHelper.cs index f9f4c8c..d4ef904 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/UsAppAuthScopeHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/UsAppAuthScopeHelper.cs @@ -27,7 +27,10 @@ public static class UsAppAuthScopeHelper } var kind = currentUser.FindClaim(UsAppJwtClaims.ClientKind)?.Value; - if (!string.Equals(kind, UsAppJwtClaims.ClientKindUsApp, StringComparison.Ordinal)) + // 美国版 us-app;泰额版 us-app-auth 转发 th-app-auth 后签发 th_app + var isAppToken = string.Equals(kind, UsAppJwtClaims.ClientKindUsApp, StringComparison.Ordinal) + || string.Equals(kind, UsAppJwtClaims.ClientKindThApp, StringComparison.Ordinal); + if (!isAppToken) { throw new UserFriendlyException("请使用 App 登录令牌调用该接口"); } @@ -53,7 +56,13 @@ public static class UsAppAuthScopeHelper return true; } - return await TeamMemberRoleHelper.IsCompanyAdminUserAsync(db, currentUser); + if (await TeamMemberRoleHelper.IsCompanyAdminUserAsync(db, currentUser)) + { + return true; + } + + // App JWT 可能未写 role claim:回退查库 RoleCode=admin + return await ReportsRoleHelper.IsAdminRoleFromDbAsync(db, currentUser.Id.Value); } public static async Task> ListCompaniesAsync(ISqlSugarClient db) diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/EmbeddedTenantSqlScripts.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/EmbeddedTenantSqlScripts.cs new file mode 100644 index 0000000..bc96c11 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/EmbeddedTenantSqlScripts.cs @@ -0,0 +1,31 @@ +using System.Reflection; + +namespace FoodLabeling.Application.MultiTenancy; + +/// +/// 从程序集嵌入资源读取租户初始化 SQL 脚本。 +/// +public static class EmbeddedTenantSqlScripts +{ + /// + /// 读取指定嵌入资源中的 SQL 文本。 + /// + /// 包含 EmbeddedResource 的程序集 + /// LogicalName 或完整资源名 + public static string Read(Assembly assembly, string resourceName) + { + var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + var available = string.Join(", ", assembly.GetManifestResourceNames()); + throw new InvalidOperationException( + $"未找到嵌入 SQL 资源「{resourceName}」。可用资源:{available}"); + } + + using (stream) + using (var reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/FoodLabelingTenantMigrationScriptNames.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/FoodLabelingTenantMigrationScriptNames.cs new file mode 100644 index 0000000..50c1523 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/FoodLabelingTenantMigrationScriptNames.cs @@ -0,0 +1,34 @@ +namespace FoodLabeling.Application.MultiTenancy; + +/// +/// 泰额版新租户业务库初始化时自动执行的嵌入 SQL 资源名(顺序即执行顺序)。 +/// +public static class FoodLabelingTenantMigrationScriptNames +{ + public const string AppliedRegionType = "FoodLabeling.TenantMigrations.fl_entity_applied_region_type.sql"; + + public const string LabelPartnerId = "FoodLabeling.TenantMigrations.fl_label_partner_id.sql"; + + public const string ProductCategoryPartnerScope = + "FoodLabeling.TenantMigrations.fl_product_category_partner_scope.sql"; + + public const string UserLocation = "FoodLabeling.TenantMigrations.fl_userlocation.sql"; + + public const string TeamMemberScope = "FoodLabeling.TenantMigrations.fl_team_member_scope.sql"; + + public const string Training = "FoodLabeling.TenantMigrations.fl_training.sql"; + + public const string LabelAlertTimer = "FoodLabeling.TenantMigrations.fl_label_alert_timer.sql"; + + /// CodeFirst 之后按序执行的脚本资源名。 + public static readonly IReadOnlyList TenantInitializationOrder = + [ + AppliedRegionType, + LabelPartnerId, + ProductCategoryPartnerScope, + UserLocation, + TeamMemberScope, + Training, + LabelAlertTimer + ]; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs index 3d4c4fa..7af2c9b 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs @@ -1,12 +1,11 @@ +using FoodLabeling.Application.Helpers; using Microsoft.AspNetCore.Http; using Volo.Abp.MultiTenancy; -using Volo.Abp.Security.Claims; -using Yi.Framework.Rbac.Domain.Shared.Consts; namespace FoodLabeling.Application.MultiTenancy; /// -/// 从 JWT Claim 解析当前租户(泰额版登录写入) +/// 从 JWT Claim TenantId 解析当前租户(泰额版登录写入) /// public class JwtClaimTenantResolveContributor : TenantResolveContributorBase { @@ -16,16 +15,17 @@ public class JwtClaimTenantResolveContributor : TenantResolveContributorBase public override Task ResolveAsync(ITenantResolveContext context) { - var httpContext = context.ServiceProvider.GetService(typeof(IHttpContextAccessor)) as IHttpContextAccessor; - var user = httpContext?.HttpContext?.User; - if (user?.Identity?.IsAuthenticated != true) + var httpContext = (context.ServiceProvider.GetService(typeof(IHttpContextAccessor)) as IHttpContextAccessor) + ?.HttpContext; + if (httpContext is null) { return Task.CompletedTask; } - var tenantClaim = user.FindFirst(TokenTypeConst.TenantId)?.Value - ?? user.FindFirst(AbpClaimTypes.TenantId)?.Value; - if (!string.IsNullOrWhiteSpace(tenantClaim)) + var tenantClaim = TenantBusinessContextHelper.TryGetTenantIdClaimValue(httpContext); + if (!string.IsNullOrWhiteSpace(tenantClaim) + && Guid.TryParse(tenantClaim, out var tenantGuid) + && tenantGuid != Guid.Empty) { context.TenantIdOrName = tenantClaim; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/TenantSqlScriptExecutor.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/TenantSqlScriptExecutor.cs new file mode 100644 index 0000000..c0bc071 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/TenantSqlScriptExecutor.cs @@ -0,0 +1,100 @@ +using System.Text; +using System.Text.RegularExpressions; +using MySqlConnector; +using SqlSugar; + +namespace FoodLabeling.Application.MultiTenancy; + +/// +/// 在 MySQL 租户库上执行幂等 DDL/DML 脚本(支持 PREPARE / 用户变量等多语句批次)。 +/// +public static class TenantSqlScriptExecutor +{ + private static readonly Regex PrepareBlockEndRegex = + new(@"DEALLOCATE\s+PREPARE\s+\w+\s*;\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline); + + private static readonly Regex CreateTableEndRegex = + new(@"\)\s*ENGINE\s*=.+;\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// + /// 执行完整 SQL 脚本;仅支持 MySQL。 + /// + public static async Task ExecuteMySqlScriptAsync( + string connectionString, + string script, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(script)) + { + return; + } + + var normalizedConnectionString = EnsureMySqlScriptConnectionOptions(connectionString); + await using var connection = new MySqlConnection(normalizedConnectionString); + await connection.OpenAsync(cancellationToken); + + foreach (var batch in SplitIntoBatches(script)) + { + await using var command = new MySqlCommand(batch, connection); + await command.ExecuteNonQueryAsync(cancellationToken); + } + } + + /// + /// 非 MySQL 租户库跳过脚本(当前脚本均为 MySQL 方言)。 + /// + public static bool Supports(DbType dbType) => dbType == DbType.MySql; + + /// + /// 将脚本拆成可独立提交的批次:PREPARE 块、CREATE TABLE、UPDATE 等。 + /// + internal static IReadOnlyList SplitIntoBatches(string script) + { + var lines = script.Replace("\r\n", "\n").Split('\n'); + var batches = new List(); + var current = new StringBuilder(); + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd(); + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + current.AppendLine(line); + + var trimmed = line.Trim(); + if (PrepareBlockEndRegex.IsMatch(trimmed) + || CreateTableEndRegex.IsMatch(trimmed) + || (trimmed.StartsWith("UPDATE ", StringComparison.OrdinalIgnoreCase) && trimmed.EndsWith(';'))) + { + var batch = current.ToString().Trim(); + if (!string.IsNullOrEmpty(batch)) + { + batches.Add(batch); + } + + current.Clear(); + } + } + + var tail = current.ToString().Trim(); + if (!string.IsNullOrEmpty(tail)) + { + batches.Add(tail); + } + + return batches; + } + + private static string EnsureMySqlScriptConnectionOptions(string connectionString) + { + var builder = new MySqlConnectionStringBuilder(connectionString) + { + AllowUserVariables = true + }; + + return builder.ConnectionString; + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs index e29bc69..d036462 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs @@ -3,6 +3,7 @@ using FoodLabeling.Application.Contracts.Dtos.AuthSession; using FoodLabeling.Application.Contracts.IServices; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.Caching; @@ -24,24 +25,28 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService private readonly IDistributedCache _systemEditStampCache; private readonly ISqlSugarDbContext _dbContext; private readonly ISqlSugarRepository _userRepository; + private readonly DbConnOptions _dbConnOptions; public AuthSessionAppService( ISqlSugarDbContext dbContext, ISqlSugarRepository userRepository, IDistributedCache userCache, - IDistributedCache systemEditStampCache) + IDistributedCache systemEditStampCache, + IOptions dbConnOptions) { _dbContext = dbContext; _userRepository = userRepository; _userCache = userCache; _systemEditStampCache = systemEditStampCache; + _dbConnOptions = dbConnOptions.Value; } /// public virtual async Task GetMyMenusAsync() { - // 平台 Token(JWT 无 TenantId)读主库;公司 Token 须已解析租户上下文 - if (CurrentTenant.Id.HasValue) + // 平台 Token(JWT 无 TenantId,或前端误传 __tenant=全 0)读主库; + // 仅真实业务租户 Id 才要求已解析租户上下文 + if (CurrentTenant.Id.HasValue && CurrentTenant.Id.Value != Guid.Empty) { TenantContextGuard.EnsureTenantResolved(CurrentTenant, "获取菜单权限"); } @@ -66,35 +71,33 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService .ToListAsync(); var distinctUserRoleIds = userRoleIds.Distinct().ToList(); + var isAdmin = UserConst.Admin.Equals(user.UserName); + var isSaasEnabled = _dbConnOptions.EnabledSaasMultiTenancy; + var isPlatformLogin = !CurrentTenant.Id.HasValue || CurrentTenant.Id.Value == Guid.Empty; + List menus; - if (UserConst.Admin.Equals(user.UserName)) + // 非 SaaS:admin 返回全部;SaaS 公司业务租户:一律走 RoleMenu(含公司 admin) + // SaaS 平台登录:下方再统一过滤为仅平台菜单 + if (isAdmin && !isSaasEnabled) + { + menus = await _dbContext.SqlSugarClient.Queryable() + .Where(x => x.IsDeleted == false) + .ToListAsync(); + } + else if (isAdmin && isSaasEnabled && isPlatformLogin) { - // MenuAggregateRoot(ParentId 为 Guid) 无法兼容 menu.ParentId=0/字符串:这里统一用 MenuDbEntity menus = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.IsDeleted == false) .ToListAsync(); } else { - var roleIdStrs = distinctUserRoleIds.Select(x => x.ToString()).Distinct().ToList(); - if (roleIdStrs.Count == 0) - { - menus = new List(); - } - else - { - var menuIds = await _dbContext.SqlSugarClient.Queryable() - .Where(x => roleIdStrs.Contains(x.RoleId)) - .Select(x => x.MenuId) - .Distinct() - .ToListAsync(); - - menus = menuIds.Count == 0 - ? new List() - : await _dbContext.SqlSugarClient.Queryable() - .Where(x => x.IsDeleted == false && menuIds.Contains(x.Id)) - .ToListAsync(); - } + menus = await LoadMenusByRoleIdsAsync(distinctUserRoleIds); + } + + if (isSaasEnabled && isPlatformLogin) + { + menus = PlatformMenuHelper.FilterPlatformMenusWithAncestors(menus); } var menuNodes = menus @@ -126,6 +129,7 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService return new CurrentUserMenuPermissionsOutputDto { + UserId = user.Id, User = new CurrentUserBriefDto { Id = user.Id, @@ -145,6 +149,30 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService } /// + /// 按用户角色关联 RoleMenu 查询可见菜单(SaaS 公司业务租户 admin 与普通用户相同路径) + /// + private async Task> LoadMenusByRoleIdsAsync(IReadOnlyList userRoleIds) + { + var roleIdStrs = userRoleIds.Select(x => x.ToString()).Distinct().ToList(); + if (roleIdStrs.Count == 0) + { + return new List(); + } + + var menuIds = await _dbContext.SqlSugarClient.Queryable() + .Where(x => roleIdStrs.Contains(x.RoleId)) + .Select(x => x.MenuId) + .Distinct() + .ToListAsync(); + + return menuIds.Count == 0 + ? new List() + : await _dbContext.SqlSugarClient.Queryable() + .Where(x => x.IsDeleted == false && menuIds.Contains(x.Id)) + .ToListAsync(); + } + + /// /// 缓存未命中时,取主要业务表最近修改时间,避免 Last Updated 长期停在用户资料时间。 /// private async Task ResolveRecentBusinessEditTimeAsync() diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs index b063965..d904887 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Text.Json; using FoodLabeling.Application.Contracts.Dtos.Dashboard; using FoodLabeling.Application.Contracts.IServices; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelAlertTimerDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelAlertTimerDbEntity.cs new file mode 100644 index 0000000..db8d7aa --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelAlertTimerDbEntity.cs @@ -0,0 +1,49 @@ +using SqlSugar; + +namespace FoodLabeling.Application.Services.DbModels; + +/// +/// 标签告警计时器(对应表:fl_label_alert_timer) +/// +[SugarTable("fl_label_alert_timer")] +public class FlLabelAlertTimerDbEntity +{ + [SugarColumn(IsPrimaryKey = true)] + public string Id { get; set; } = string.Empty; + + public string BatchId { get; set; } = string.Empty; + + public string PrintTaskId { get; set; } = string.Empty; + + public string LabelId { get; set; } = string.Empty; + + public string? LabelCode { get; set; } + + public string LabelName { get; set; } = string.Empty; + + public string? ProductId { get; set; } + + public string? ProductName { get; set; } + + public string LocationId { get; set; } = string.Empty; + + public DateTime PrintedAt { get; set; } + + public DateTime? BaseTime { get; set; } + + public DateTime ExpiresAt { get; set; } + + public int DurationSeconds { get; set; } + + public string Title { get; set; } = string.Empty; + + public string Subtitle { get; set; } = string.Empty; + + public bool IsDeleted { get; set; } + + public DateTime? DeletionTime { get; set; } + + public string? CreatedBy { get; set; } + + public DateTime CreationTime { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelCategoryDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelCategoryDbEntity.cs index 7427ea6..93ef527 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelCategoryDbEntity.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelCategoryDbEntity.cs @@ -49,6 +49,11 @@ public class FlLabelCategoryDbEntity public string AvailabilityType { get; set; } = "ALL"; /// + /// 适用 Region 范围:ALL / SPECIFIED(ALL 时可同时 SPECIFIED 门店快照,表示全区下指定门店) + /// + public string AppliedRegionType { get; set; } = "ALL"; + + /// /// 适用 Company 范围:ALL / SPECIFIED /// public string AppliedPartnerType { get; set; } = "ALL"; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelDbEntity.cs index 88054d5..26d434c 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelDbEntity.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelDbEntity.cs @@ -28,6 +28,11 @@ public class FlLabelDbEntity public string? LocationId { get; set; } + /// + /// 适用 Company(fl_partner.Id,单选);Region/Location 为 ALL 时用于回显与范围校验。 + /// + public string? PartnerId { get; set; } + public string? LabelCategoryId { get; set; } public string? LabelTypeId { get; set; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelMultipleOptionDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelMultipleOptionDbEntity.cs index c153dad..3094e5b 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelMultipleOptionDbEntity.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelMultipleOptionDbEntity.cs @@ -36,6 +36,11 @@ public class FlLabelMultipleOptionDbEntity public string AvailabilityType { get; set; } = "ALL"; /// + /// 适用 Region 范围:ALL / SPECIFIED + /// + public string AppliedRegionType { get; set; } = "ALL"; + + /// /// 适用 Company 范围:ALL / SPECIFIED /// public string AppliedPartnerType { get; set; } = "ALL"; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTypeDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTypeDbEntity.cs index 1a7bccc..96fc90e 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTypeDbEntity.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTypeDbEntity.cs @@ -34,6 +34,11 @@ public class FlLabelTypeDbEntity public string AvailabilityType { get; set; } = "ALL"; /// + /// 适用 Region 范围:ALL / SPECIFIED + /// + public string AppliedRegionType { get; set; } = "ALL"; + + /// /// 适用 Company 范围:ALL / SPECIFIED /// public string AppliedPartnerType { get; set; } = "ALL"; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs index 2852756..7874286 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs @@ -46,6 +46,16 @@ public class FlProductCategoryDbEntity /// public string AvailabilityType { get; set; } = "ALL"; + /// + /// 适用 Region 范围:ALL / SPECIFIED(ALL 时可同时 SPECIFIED 门店快照) + /// + public string AppliedRegionType { get; set; } = "ALL"; + + /// + /// 适用 Company 范围:ALL / SPECIFIED + /// + public string AppliedPartnerType { get; set; } = "ALL"; + public int OrderNum { get; set; } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryPartnerDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryPartnerDbEntity.cs new file mode 100644 index 0000000..e4c92a5 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryPartnerDbEntity.cs @@ -0,0 +1,18 @@ +using SqlSugar; + +namespace FoodLabeling.Application.Services.DbModels; + +[SugarTable("fl_product_category_partner")] +public class FlProductCategoryPartnerDbEntity +{ + [SugarColumn(IsPrimaryKey = true)] + public string Id { get; set; } = string.Empty; + + public string CategoryId { get; set; } = string.Empty; + + public string PartnerId { get; set; } = string.Empty; + + public DateTime CreationTime { get; set; } + + public string? CreatorId { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs index 7d31ad8..6c39b92 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs @@ -36,4 +36,9 @@ public class FlProductDbEntity /// 适用门店:ALL / SPECIFIED(ALL 时动态包含后续新增门店) /// public string AvailabilityType { get; set; } = "SPECIFIED"; + + /// + /// 适用 Region:ALL / SPECIFIED(支持 Region=ALL + 指定门店) + /// + public string AppliedRegionType { get; set; } = "ALL"; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTeamMemberScopeDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTeamMemberScopeDbEntity.cs new file mode 100644 index 0000000..bf0ac0a --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTeamMemberScopeDbEntity.cs @@ -0,0 +1,27 @@ +using SqlSugar; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Application.Services.DbModels; + +/// +/// Team Member 适用范围维度(Region/Location 的 ALL/SPECIFIED),与 userlocation 快照配合回显。 +/// +[IgnoreCodeFirst] +[SugarTable("fl_team_member_scope")] +public class FlTeamMemberScopeDbEntity +{ + [SugarColumn(IsPrimaryKey = true, Length = 36)] + public string UserId { get; set; } = string.Empty; + + /// 适用 Region:ALL / SPECIFIED + [SugarColumn(Length = 20)] + public string AppliedRegionType { get; set; } = "SPECIFIED"; + + /// 适用 Location:ALL / SPECIFIED + [SugarColumn(Length = 20)] + public string AppliedLocationType { get; set; } = "SPECIFIED"; + + public DateTime? CreationTime { get; set; } + + public DateTime? LastModificationTime { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingCategoryDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingCategoryDbEntity.cs new file mode 100644 index 0000000..0af87e5 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingCategoryDbEntity.cs @@ -0,0 +1,28 @@ +using SqlSugar; + +namespace FoodLabeling.Application.Services.DbModels; + +[SugarTable("fl_training_category")] +public class FlTrainingCategoryDbEntity +{ + [SugarColumn(IsPrimaryKey = true)] + public string Id { get; set; } = string.Empty; + + public string CategoryName { get; set; } = string.Empty; + + public string? ParentId { get; set; } + + public int OrderNum { get; set; } + + public bool IsDeleted { get; set; } + + public DateTime CreationTime { get; set; } + + public string? CreatorId { get; set; } + + public DateTime? LastModificationTime { get; set; } + + public string? LastModifierId { get; set; } + + public string ConcurrencyStamp { get; set; } = string.Empty; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileDbEntity.cs new file mode 100644 index 0000000..5714d9b --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileDbEntity.cs @@ -0,0 +1,41 @@ +using SqlSugar; + +namespace FoodLabeling.Application.Services.DbModels; + +[SugarTable("fl_training_file")] +public class FlTrainingFileDbEntity +{ + [SugarColumn(IsPrimaryKey = true)] + public string Id { get; set; } = string.Empty; + + public string CategoryId { get; set; } = string.Empty; + + public string FileName { get; set; } = string.Empty; + + public string FileUrl { get; set; } = string.Empty; + + /// image / doc / other + public string FileType { get; set; } = "other"; + + public long FileSize { get; set; } + + public int OrderNum { get; set; } + + public string AppliedPartnerType { get; set; } = "ALL"; + + public string AppliedRegionType { get; set; } = "ALL"; + + public string AvailabilityType { get; set; } = "ALL"; + + public bool IsDeleted { get; set; } + + public DateTime CreationTime { get; set; } + + public string? CreatorId { get; set; } + + public DateTime? LastModificationTime { get; set; } + + public string? LastModifierId { get; set; } + + public string ConcurrencyStamp { get; set; } = string.Empty; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileLocationDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileLocationDbEntity.cs new file mode 100644 index 0000000..1905c74 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileLocationDbEntity.cs @@ -0,0 +1,18 @@ +using SqlSugar; + +namespace FoodLabeling.Application.Services.DbModels; + +[SugarTable("fl_training_file_location")] +public class FlTrainingFileLocationDbEntity +{ + [SugarColumn(IsPrimaryKey = true)] + public string Id { get; set; } = string.Empty; + + public string TrainingFileId { get; set; } = string.Empty; + + public string LocationId { get; set; } = string.Empty; + + public DateTime CreationTime { get; set; } + + public string? CreatorId { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFilePartnerDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFilePartnerDbEntity.cs new file mode 100644 index 0000000..be30075 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFilePartnerDbEntity.cs @@ -0,0 +1,18 @@ +using SqlSugar; + +namespace FoodLabeling.Application.Services.DbModels; + +[SugarTable("fl_training_file_partner")] +public class FlTrainingFilePartnerDbEntity +{ + [SugarColumn(IsPrimaryKey = true)] + public string Id { get; set; } = string.Empty; + + public string TrainingFileId { get; set; } = string.Empty; + + public string PartnerId { get; set; } = string.Empty; + + public DateTime CreationTime { get; set; } + + public string? CreatorId { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileRegionDbEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileRegionDbEntity.cs new file mode 100644 index 0000000..a1d5178 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileRegionDbEntity.cs @@ -0,0 +1,18 @@ +using SqlSugar; + +namespace FoodLabeling.Application.Services.DbModels; + +[SugarTable("fl_training_file_region")] +public class FlTrainingFileRegionDbEntity +{ + [SugarColumn(IsPrimaryKey = true)] + public string Id { get; set; } = string.Empty; + + public string TrainingFileId { get; set; } = string.Empty; + + public string GroupId { get; set; } = string.Empty; + + public DateTime CreationTime { get; set; } + + public string? CreatorId { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs index 8d05650..c63f9af 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs @@ -4,6 +4,7 @@ using FoodLabeling.Application.Contracts.Dtos.Group; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Services.DbModels; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; @@ -25,16 +26,22 @@ public class GroupAppService : ApplicationService, IGroupAppService private readonly ISqlSugarDbContext _dbContext; private readonly IGuidGenerator _guidGenerator; + private readonly DbConnOptions _dbConnOptions; - public GroupAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator) + public GroupAppService( + ISqlSugarDbContext dbContext, + IGuidGenerator guidGenerator, + IOptions dbConnOptions) { _dbContext = dbContext; _guidGenerator = guidGenerator; + _dbConnOptions = dbConnOptions.Value; } /// public async Task> GetListAsync(GroupGetListInputVo input) { + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Region"); RefAsync total = 0; var query = await BuildGroupJoinedQueryAsync(input); var projected = query.Select((g, p) => new GroupGetListOutputDto diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAlertTimerAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAlertTimerAppService.cs new file mode 100644 index 0000000..9051c2f --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAlertTimerAppService.cs @@ -0,0 +1,398 @@ +using FoodLabeling.Application.Contracts.Dtos.Common; +using FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer; +using FoodLabeling.Application.Contracts.IServices; +using FoodLabeling.Application.Helpers; +using FoodLabeling.Application.Services.DbModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Distributed; +using SqlSugar; +using Volo.Abp; +using Volo.Abp.Application.Services; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Application.Services; + +/// +/// 标签告警计时器(App):跟踪已打印标签的过期倒计时;与可否打印无关。 +/// +public class LabelAlertTimerAppService : ApplicationService, ILabelAlertTimerAppService +{ + private const string StatusRunning = "running"; + private const string StatusExpired = "expired"; + + private readonly ISqlSugarDbContext _dbContext; + private readonly IDistributedCache _distributedCache; + + public LabelAlertTimerAppService(ISqlSugarDbContext dbContext, IDistributedCache distributedCache) + { + _dbContext = dbContext; + _distributedCache = distributedCache; + } + + /// + /// 分页查询当前门店告警计时器列表(含倒计时) + /// + /// + /// 仅展示已打印标签的过期倒计时,用于判断能否打印。 + /// 过期时刻与 Print Log「Expiration」列同源(ReportsPrintLogExpiryHelper)。 + /// 同一打印批次(BatchId)无论打印多少张标签,仅一条计时器(取 CopyIndex 最小任务)。 + /// + /// 示例请求: + /// ```json + /// { + /// "locationId": "11111111-1111-1111-1111-111111111111", + /// "skipCount": 1, + /// "maxResultCount": 20, + /// "dateDay": "2026-08-07" + /// } + /// ``` + /// + /// 参数说明: + /// - locationId: 当前门店 Id(必填,须已绑定) + /// - skipCount: 页码(从 1 开始) + /// - maxResultCount: 每页条数 + /// - dateDay: 可选,按 PrintedAt 自然日筛选(yyyy-MM-dd) + /// + /// 分页查询入参 + /// 分页计时器列表(含 remainingTime 倒计时秒数) + /// 成功返回分页列表 + /// 参数错误或未登录/无门店权限 + /// 服务器错误 + [Authorize] + [HttpPost("label-alert-timer/list")] + public virtual async Task> GetListAsync( + LabelAlertTimerGetListInputVo input) + { + if (input is null) + { + throw new UserFriendlyException("入参不能为空"); + } + + if (!CurrentUser.Id.HasValue) + { + throw new UserFriendlyException("用户未登录"); + } + + var locationId = input.LocationId?.Trim(); + if (string.IsNullOrWhiteSpace(locationId)) + { + throw new UserFriendlyException("门店Id不能为空"); + } + + return await QueryListByLocationAsync(locationId, input); + } + + /// + /// App:当前账号当前门店告警列表(含倒计时) + /// + /// + /// 供 App 警告页使用:按当前登录账号可访问的门店查询已打印标签的告警倒计时。 + /// locationId 可省略,省略时使用管理员已选门店缓存(select-admin-scope-location); + /// 仍无门店时返回友好错误。过期状态仅用于展示,与可否打印无关。 + /// + /// 示例请求: + /// ```json + /// { + /// "locationId": "11111111-1111-1111-1111-111111111111", + /// "skipCount": 1, + /// "maxResultCount": 50 + /// } + /// ``` + /// + /// 参数说明: + /// - locationId: 当前门店 Id(可选;空则取已选门店缓存) + /// - skipCount: 页码(从 1 开始) + /// - maxResultCount: 每页条数 + /// - dateDay: 可选,按 PrintedAt 自然日筛选(yyyy-MM-dd) + /// + /// 出参倒计时字段: + /// - remainingTime: 剩余秒数,App 可直接做倒计时 + /// - totalTime: 总时长(秒) + /// - status: running / expired + /// - expiresAt: 过期时刻 + /// + /// 分页查询入参 + /// 分页告警列表(含倒计时) + /// 成功返回分页列表 + /// 未登录、无门店或无权限 + /// 服务器错误 + [Authorize] + [HttpPost("label-alert-timer/app-list")] + public virtual async Task> GetAppListAsync( + LabelAlertTimerGetListInputVo input) + { + if (input is null) + { + throw new UserFriendlyException("入参不能为空"); + } + + if (!CurrentUser.Id.HasValue) + { + throw new UserFriendlyException("用户未登录"); + } + + var locationId = input.LocationId?.Trim(); + if (string.IsNullOrWhiteSpace(locationId)) + { + var cache = await UsAppAuthScopeHelper.GetAdminScopeCacheAsync( + _distributedCache, + CurrentUser.Id.Value); + locationId = cache?.Location?.Id?.Trim(); + } + + if (string.IsNullOrWhiteSpace(locationId)) + { + throw new UserFriendlyException("请先选择门店或传入 locationId"); + } + + return await QueryListByLocationAsync(locationId, input); + } + + /// + /// 软删除告警计时器 + /// + /// + /// 删除前校验当前用户可访问该计时器所属门店。 + /// + /// 计时器 Id + /// 删除成功 + /// 记录不存在或无门店权限 + /// 服务器错误 + [Authorize] + [HttpDelete("label-alert-timer/{id}")] + public virtual async Task DeleteAsync(string id) + { + var timerId = id?.Trim(); + if (string.IsNullOrWhiteSpace(timerId)) + { + throw new UserFriendlyException("计时器Id不能为空"); + } + + if (!CurrentUser.Id.HasValue) + { + throw new UserFriendlyException("用户未登录"); + } + + var db = _dbContext.SqlSugarClient; + var row = (await db.Queryable() + .Where(x => x.Id == timerId && !x.IsDeleted) + .Take(1) + .ToListAsync()) + .FirstOrDefault(); + if (row is null) + { + throw new UserFriendlyException("计时器不存在或已删除"); + } + + await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync( + CurrentUser, db, row.LocationId); + + var now = DateTime.Now; + await db.Updateable() + .SetColumns(x => x.IsDeleted == true) + .SetColumns(x => x.DeletionTime == now) + .Where(x => x.Id == timerId && !x.IsDeleted) + .ExecuteCommandAsync(); + } + + /// + /// 查询已打印标签告警的过期/倒计时状态(不拦截打印) + /// + /// + /// 至少提供 timerIdbatchIdprintTaskId 之一。 + /// 本接口仅返回已打印批次的过期状态与剩余秒数,供展示倒计时; + /// 绝不用于判断「能不能打印」——打印流程不得依赖本接口结果做拦截。 + /// + /// 示例请求: + /// ```json + /// { + /// "batchId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + /// } + /// ``` + /// + /// 参数说明: + /// - timerId: 计时器 Id + /// - batchId: 打印批次 Id + /// - printTaskId: 打印任务 Id(同批次任意任务均可) + /// + /// 查询入参 + /// 过期/倒计时状态(展示用) + /// 成功(含未找到记录的情况) + /// 未提供任何标识 + /// 服务器错误 + [Authorize] + [HttpPost("label-alert-timer/check-expired")] + public virtual async Task CheckExpiredAsync( + LabelAlertTimerCheckExpiredInputVo input) + { + if (input is null) + { + throw new UserFriendlyException("入参不能为空"); + } + + var timerId = input.TimerId?.Trim(); + var batchId = input.BatchId?.Trim(); + var printTaskId = input.PrintTaskId?.Trim(); + if (string.IsNullOrWhiteSpace(timerId) && + string.IsNullOrWhiteSpace(batchId) && + string.IsNullOrWhiteSpace(printTaskId)) + { + throw new UserFriendlyException("请至少提供 timerId、batchId 或 printTaskId 之一"); + } + + var db = _dbContext.SqlSugarClient; + FlLabelAlertTimerDbEntity? row = null; + + if (!string.IsNullOrWhiteSpace(timerId)) + { + row = (await db.Queryable() + .Where(x => x.Id == timerId && !x.IsDeleted) + .Take(1) + .ToListAsync()) + .FirstOrDefault(); + } + else if (!string.IsNullOrWhiteSpace(batchId)) + { + row = (await db.Queryable() + .Where(x => x.BatchId == batchId && !x.IsDeleted) + .Take(1) + .ToListAsync()) + .FirstOrDefault(); + } + else if (!string.IsNullOrWhiteSpace(printTaskId)) + { + var task = (await db.Queryable() + .Where(x => x.Id == printTaskId) + .Take(1) + .ToListAsync()) + .FirstOrDefault(); + if (task is not null && !string.IsNullOrWhiteSpace(task.BatchId)) + { + row = (await db.Queryable() + .Where(x => x.BatchId == task.BatchId && !x.IsDeleted) + .Take(1) + .ToListAsync()) + .FirstOrDefault(); + } + } + + if (row is null) + { + return new LabelAlertTimerCheckExpiredOutputDto + { + Found = false, + IsExpired = false, + RemainingSeconds = 0 + }; + } + + if (!CurrentUser.Id.HasValue) + { + throw new UserFriendlyException("用户未登录"); + } + + await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync( + CurrentUser, db, row.LocationId); + + var now = DateTime.Now; + var remaining = Math.Max(0, (int)(row.ExpiresAt - now).TotalSeconds); + var isExpired = row.ExpiresAt <= now; + + return new LabelAlertTimerCheckExpiredOutputDto + { + Found = true, + IsExpired = isExpired, + ExpiresAt = row.ExpiresAt, + RemainingSeconds = remaining, + Status = isExpired ? StatusExpired : StatusRunning, + Title = row.Title, + Subtitle = row.Subtitle, + TimerId = row.Id, + BatchId = row.BatchId + }; + } + + private async Task> QueryListByLocationAsync( + string locationId, + LabelAlertTimerGetListInputVo input) + { + await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync( + CurrentUser, _dbContext.SqlSugarClient, locationId); + + var db = _dbContext.SqlSugarClient; + RefAsync total = 0; + var query = db.Queryable() + .Where(x => !x.IsDeleted && x.LocationId == locationId); + + var (dayStart, dayEndExcl) = ResolveDateDayFilter(input.DateDay); + if (dayStart.HasValue && dayEndExcl.HasValue) + { + var start = dayStart.Value; + var endExcl = dayEndExcl.Value; + query = query.Where(x => x.PrintedAt >= start && x.PrintedAt < endExcl); + } + + var pageRows = await query + .OrderBy(x => x.ExpiresAt, OrderByType.Desc) + .OrderBy(x => x.PrintedAt, OrderByType.Desc) + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total); + + var now = DateTime.Now; + var items = pageRows.Select(x => MapListItem(x, now)).ToList(); + + var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount; + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount); + var totalCount = (long)total; + var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize); + + return new PagedResultWithPageDto + { + PageIndex = pageIndex, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages, + Items = items + }; + } + + private static LabelAlertTimerListItemDto MapListItem(FlLabelAlertTimerDbEntity row, DateTime now) + { + var remaining = Math.Max(0, (int)(row.ExpiresAt - now).TotalSeconds); + var isExpired = row.ExpiresAt <= now; + + return new LabelAlertTimerListItemDto + { + Id = row.Id, + BatchId = row.BatchId, + PrintTaskId = row.PrintTaskId, + LabelId = row.LabelId, + LabelCode = row.LabelCode, + Title = row.Title, + Subtitle = row.Subtitle, + TotalTime = row.DurationSeconds, + RemainingTime = remaining, + Status = isExpired ? StatusExpired : StatusRunning, + ExpiresAt = row.ExpiresAt, + PrintedAt = row.PrintedAt, + LocationId = row.LocationId, + ProductName = row.ProductName + }; + } + + private static (DateTime? DayStart, DateTime? DayEndExcl) ResolveDateDayFilter(string? dateDay) + { + if (string.IsNullOrWhiteSpace(dateDay)) + { + return (null, null); + } + + if (DateTime.TryParse(dateDay.Trim(), out var parsedDay)) + { + var day = parsedDay.Date; + return (day, day.AddDays(1)); + } + + return (null, null); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs index c478615..6679748 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs @@ -6,6 +6,8 @@ using FoodLabeling.Application.Contracts.Dtos.LabelTemplate; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Domain.Entities; +using FoodLabeling.Domain.Shared.Helpers; +using Microsoft.Extensions.Options; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Services; @@ -22,15 +24,21 @@ public class LabelAppService : ApplicationService, ILabelAppService { private readonly ISqlSugarDbContext _dbContext; private readonly IGuidGenerator _guidGenerator; + private readonly DbConnOptions _dbConnOptions; - public LabelAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator) + public LabelAppService( + ISqlSugarDbContext dbContext, + IGuidGenerator guidGenerator, + IOptions dbConnOptions) { _dbContext = dbContext; _guidGenerator = guidGenerator; + _dbConnOptions = dbConnOptions.Value; } public async Task> GetListAsync(LabelGetListInputVo input) { + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Label"); RefAsync total = 0; var productId = input.ProductId?.Trim(); @@ -50,7 +58,11 @@ public class LabelAppService : ApplicationService, ILabelAppService .Where(l => !l.IsDeleted) .WhereIF(!string.IsNullOrWhiteSpace(labelCategoryId), l => l.LabelCategoryId == labelCategoryId) .WhereIF(!string.IsNullOrWhiteSpace(labelTypeId), l => l.LabelTypeId == labelTypeId) - .WhereIF(input.State != null, l => l.State == input.State); + .WhereIF(input.State != null, l => l.State == input.State) + // 已落库 PartnerId 的标签:按公司筛选时排除其他公司 + .WhereIF( + !string.IsNullOrWhiteSpace(partnerId), + l => l.PartnerId == null || l.PartnerId == "" || l.PartnerId == partnerId); if (!string.IsNullOrWhiteSpace(templateCode)) { @@ -69,11 +81,16 @@ public class LabelAppService : ApplicationService, ILabelAppService groupId, locationId); var filterGroupId = groupId?.Trim(); - if (scopedLocationIds is not null) + var applyLocationFilter = LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter( + partnerId, groupId, locationId); + + if (scopedLocationIds is not null && applyLocationFilter) { if (scopedLocationIds.Count == 0) { - labelIdsQuery = labelIdsQuery.Where(_ => false); + // 无可见门店:仍保留 AppliedRegionType=ALL + labelIdsQuery = LabelRegionScopeHelper.ApplyLabelAllRegionOnlyFilter( + db, labelIdsQuery, regionSchema); } else if (!string.IsNullOrWhiteSpace(filterGroupId)) { @@ -86,6 +103,24 @@ public class LabelAppService : ApplicationService, ILabelAppService db, labelIdsQuery, scopedLocationIds, regionSchema); } } + else if (!string.IsNullOrWhiteSpace(partnerId) && !applyLocationFilter) + { + // 仅 PartnerId:Region=ALL 或与该公司门店/区域有交集 + if (scopedLocationIds is null) + { + // 管理员且未展开到门店:不过滤 + } + else if (scopedLocationIds.Count == 0) + { + labelIdsQuery = LabelRegionScopeHelper.ApplyLabelAllRegionOnlyFilter( + db, labelIdsQuery, regionSchema); + } + else + { + labelIdsQuery = LabelRegionScopeHelper.ApplyLabelLocationListFilter( + db, labelIdsQuery, scopedLocationIds, regionSchema); + } + } // 按产品筛选:存在 label-product 关联即可 if (!string.IsNullOrWhiteSpace(productId)) @@ -193,6 +228,7 @@ public class LabelAppService : ApplicationService, ILabelAppService db, lid, applied, locIds); locationScopeMap[lid] = await LabelRegionScopeHelper.BuildLocationDisplayAsync( db, applied, locIds); + locationIdsMap[lid] = locationScopeMap[lid].LocationIds; } // 查询 products 并拼接 @@ -307,37 +343,51 @@ public class LabelAppService : ApplicationService, ILabelAppService List regionIdsForDto; List partnerIds; + var storedPartnerId = label.PartnerId?.Trim(); var isDynamicAll = string.Equals(appliedRegionType, LabelRegionScopeHelper.AppliedRegionAll, StringComparison.OrdinalIgnoreCase) && locationIdList.Count == 0; - // ALL 落库不写 Id 快照;详情回显展开为当前可见全集,便于编辑页 Select All if (isDynamicAll) { - var scopedLocationIds = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - if (scopedLocationIds is null) + if (!string.IsNullOrWhiteSpace(storedPartnerId)) { - locationIdList = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, null, null); - regionIdsForDto = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, null); - partnerIds = await AllScopeBindingHelper.ResolveAllPartnerIdsAsync(db); + partnerIds = new List { storedPartnerId }; } else { - locationIdList = scopedLocationIds; - regionIdsForDto = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( - db, locationIdList); - partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( - db, locationIdList); + var scopedLocationIds = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( + CurrentUser, _dbContext, null, null, null); + partnerIds = scopedLocationIds is null + ? new List { AllScopeBindingHelper.ScopeAll } + : await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, scopedLocationIds); } + + regionIdsForDto = new List { AllScopeBindingHelper.ScopeAll }; + locationIdList = new List { AllScopeBindingHelper.ScopeAll }; } else { - partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( - db, locationIdList); + partnerIds = !string.IsNullOrWhiteSpace(storedPartnerId) + ? new List { storedPartnerId } + : await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, locationIdList); var storedRegionScope = await LabelRegionScopeHelper.BuildScopeDisplayAsync( db, label.Id, appliedRegionType, locationIdList); regionIdsForDto = storedRegionScope.RegionIds; + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, + partnerIds, + regionIdsForDto, + locationIdList, + ScopeAllEchoHelper.ForLabelRegionScope(appliedRegionType, locationIdList)); + // 已显式落库 PartnerId 时不折叠成 Company ALL + if (string.IsNullOrWhiteSpace(storedPartnerId)) + { + partnerIds = collapsed.PartnerIds; + } + + regionIdsForDto = collapsed.RegionIds; + locationIdList = collapsed.LocationIds; } var regionDisplay = isDynamicAll @@ -349,6 +399,11 @@ public class LabelAppService : ApplicationService, ILabelAppService : (await LabelRegionScopeHelper.BuildLocationDisplayAsync( db, appliedRegionType, locationIdList)).Location; + var companyIdsEcho = partnerIds.Count == 1 + && !AllScopeBindingHelper.IsDeclaredAll(partnerIds[0]) + ? new List { partnerIds[0] } + : partnerIds.Where(x => !AllScopeBindingHelper.IsDeclaredAll(x)).ToList(); + return new LabelGetOutputDto { Id = label.LabelCode ?? string.Empty, @@ -357,8 +412,9 @@ public class LabelAppService : ApplicationService, ILabelAppService LocationIds = locationIdList, Location = locationDisplay, LocationName = locationDisplay, - PartnerId = partnerIds.Count > 0 ? partnerIds[0] : null, + PartnerId = companyIdsEcho.Count > 0 ? companyIdsEcho[0] : (partnerIds.Count > 0 ? partnerIds[0] : null), PartnerIds = partnerIds, + CompanyIds = companyIdsEcho, AppliedRegionType = appliedRegionType, Region = regionDisplay, RegionIds = regionIdsForDto, @@ -381,7 +437,7 @@ public class LabelAppService : ApplicationService, ILabelAppService var labelCode = input.LabelCode?.Trim(); if (string.IsNullOrWhiteSpace(labelCode)) { - labelCode = $"LBL_{_guidGenerator.Create():N}"; + labelCode = await GenerateUniqueLabelCodeAsync(); } var labelName = input.LabelName?.Trim(); @@ -406,13 +462,16 @@ public class LabelAppService : ApplicationService, ILabelAppService await EnsureLabelTypeExistsIfProvidedAsync(input.LabelTypeId); + var partnerId = await ResolveSinglePartnerIdForSaveAsync(input.PartnerId, input.PartnerIds, input.CompanyIds); + var partnerContext = string.IsNullOrWhiteSpace(partnerId) ? null : new[] { partnerId }; var scope = await LabelRegionScopeHelper.ResolveScopeForSaveAsync( _dbContext.SqlSugarClient, input.AppliedRegionType, input.RegionIds, input.GroupIds, input.LocationId, - input.LocationIds); + input.LocationIds, + partnerContext); var template = await LabelTemplateQueryHelper.QueryProjected(_dbContext.SqlSugarClient) .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim()); @@ -445,6 +504,7 @@ public class LabelAppService : ApplicationService, ILabelAppService LabelName = labelName, TemplateId = template.Id, LocationId = scope.PrimaryLocationId, + PartnerId = partnerId, LabelCategoryId = input.LabelCategoryId?.Trim(), LabelTypeId = NormalizeOptionalLabelTypeId(input.LabelTypeId), State = input.State, @@ -670,6 +730,7 @@ public class LabelAppService : ApplicationService, ILabelAppService TemplateCode = templateCode, PartnerId = item.PartnerId, PartnerIds = item.PartnerIds, + CompanyIds = item.CompanyIds, AppliedRegionType = item.AppliedRegionType, RegionIds = item.RegionIds, GroupIds = item.GroupIds, @@ -715,13 +776,16 @@ public class LabelAppService : ApplicationService, ILabelAppService await EnsureLabelTypeExistsIfProvidedAsync(input.LabelTypeId); + var partnerId = await ResolveSinglePartnerIdForSaveAsync(input.PartnerId, input.PartnerIds, input.CompanyIds); + var partnerContext = string.IsNullOrWhiteSpace(partnerId) ? null : new[] { partnerId }; var scope = await LabelRegionScopeHelper.ResolveScopeForSaveAsync( _dbContext.SqlSugarClient, input.AppliedRegionType, input.RegionIds, input.GroupIds, input.LocationId, - input.LocationIds); + input.LocationIds, + partnerContext); var template = await LabelTemplateQueryHelper.QueryProjected(_dbContext.SqlSugarClient) .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim()); @@ -735,6 +799,7 @@ public class LabelAppService : ApplicationService, ILabelAppService label.LabelName = input.LabelName?.Trim() ?? label.LabelName; label.TemplateId = template.Id; label.LocationId = scope.PrimaryLocationId; + label.PartnerId = partnerId; label.LabelCategoryId = input.LabelCategoryId?.Trim(); label.LabelTypeId = NormalizeOptionalLabelTypeId(input.LabelTypeId); label.State = input.State; @@ -1106,6 +1171,78 @@ public class LabelAppService : ApplicationService, ILabelAppService return string.IsNullOrWhiteSpace(id) ? null : id; } + /// + /// 标签适用 Company 单选:合并 partnerId / partnerIds / companyIds,拒绝 ALL 与多选。 + /// + private async Task ResolveSinglePartnerIdForSaveAsync( + string? partnerId, + IReadOnlyList? partnerIds, + IReadOnlyList? companyIds) + { + var merged = LabelEntityPartnerScopeHelper.NormalizePartnerIds(partnerIds, companyIds); + if (!string.IsNullOrWhiteSpace(partnerId)) + { + var pid = partnerId.Trim(); + if (LocationScopeBindingHelper.IsAllScopeSentinel(pid)) + { + throw new UserFriendlyException("标签适用 Company 不支持 ALL,请传单个具体 Company Id"); + } + + if (merged.Count > 0 + && (merged.Count > 1 + || !string.Equals(merged[0], pid, StringComparison.OrdinalIgnoreCase))) + { + throw new UserFriendlyException("partnerId 与 companyIds/partnerIds 不一致"); + } + + merged = new List { pid }; + } + + if (AllScopeBindingHelper.HasAllScopeSentinelSelection(merged)) + { + throw new UserFriendlyException("标签适用 Company 不支持 ALL,请传单个具体 Company Id"); + } + + var concrete = LocationScopeBindingHelper.FilterConcreteScopeIds(merged); + if (concrete.Count > 1) + { + throw new UserFriendlyException("标签适用 Company 仅支持单选(companyIds 最多传 1 个)"); + } + + if (concrete.Count == 0) + { + return null; + } + + var exists = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.Id == concrete[0]); + if (!exists) + { + throw new UserFriendlyException("存在无效的 Company(partnerId/companyIds),请刷新后重试"); + } + + return concrete[0]; + } + + /// + /// 生成未删除数据中不重复的 LB_ 前缀标签编码。 + /// + private async Task GenerateUniqueLabelCodeAsync() + { + for (var i = 0; i < 8; i++) + { + var code = $"LB_{YitIdHelper.NextId()}"; + var exists = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.LabelCode == code); + if (!exists) + { + return code; + } + } + + throw new UserFriendlyException("无法生成唯一标签编码,请稍后重试或手动填写编码"); + } + private async Task EnsureLabelTypeExistsIfProvidedAsync(string? labelTypeId) { var id = NormalizeOptionalLabelTypeId(labelTypeId); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelCategoryAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelCategoryAppService.cs index df835c8..8aefcf1 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelCategoryAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelCategoryAppService.cs @@ -4,6 +4,7 @@ using FoodLabeling.Application.Contracts.Dtos.LabelCategory; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Domain.Entities; +using FoodLabeling.Domain.Shared.Helpers; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Services; @@ -122,23 +123,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ var dto = MapToGetOutput(entity); await ApplyPartnerScopeToGetOutputAsync(dto, entity.Id); - if (string.Equals(entity.AvailabilityType, "ALL", StringComparison.OrdinalIgnoreCase)) - { - var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - var (partners, regions, locations) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, dto.PartnerIds); - if (dto.PartnerIds.Count == 0) - { - dto.PartnerIds = partners; - dto.CompanyIds = partners; - } - - dto.RegionIds = regions; - dto.GroupIds = regions; - dto.LocationIds = locations; - } - else if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) { var locationIds = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.CategoryId == entity.Id) @@ -151,29 +136,48 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ dto.GroupIds = regionIds; } + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + dto.PartnerIds, + dto.RegionIds, + dto.LocationIds, + ScopeAllEchoHelper.ForLabelEntityScope( + dto.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType)); + dto.PartnerIds = collapsed.PartnerIds; + dto.CompanyIds = collapsed.PartnerIds; + dto.RegionIds = collapsed.RegionIds; + dto.GroupIds = collapsed.RegionIds; + dto.LocationIds = collapsed.LocationIds; + return dto; } public async Task CreateAsync(LabelCategoryCreateInputVo input) { - var code = input.CategoryCode?.Trim(); var name = input.CategoryName?.Trim(); - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name)) + if (string.IsNullOrWhiteSpace(name)) { - throw new UserFriendlyException("分类编码和名称不能为空"); + throw new UserFriendlyException("分类名称不能为空"); } - var displayText = input.DisplayText?.Trim(); - var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); - - var duplicated = await _dbContext.SqlSugarClient.Queryable() - .AnyAsync(x => !x.IsDeleted && (x.CategoryCode == code || x.CategoryName == name)); - if (duplicated) + var code = input.CategoryCode?.Trim(); + if (string.IsNullOrWhiteSpace(code)) + { + code = await GenerateUniqueLabelCategoryCodeAsync(); + } + else { - throw new UserFriendlyException("分类编码或名称已存在"); + await EnsureLabelCategoryCodeNotDuplicatedAsync(code); } + await EnsureLabelCategoryNameNotDuplicatedAsync(name); + + var displayText = input.DisplayText?.Trim(); + var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); + await PurgeSoftDeletedLabelCategoriesByCodeAsync(code); var now = DateTime.Now; @@ -194,8 +198,9 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ State = input.State, ButtonAppearance = appearance, AvailabilityType = availabilityType, + AppliedRegionType = appliedRegionType, AppliedPartnerType = partnerScope.AppliedPartnerType, - OrderNum = input.OrderNum + OrderNum = input.OrderNum ?? 0 }; await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); @@ -213,22 +218,32 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ throw new UserFriendlyException("标签分类不存在"); } - var code = input.CategoryCode?.Trim(); + var codeInput = input.CategoryCode?.Trim(); + var code = string.IsNullOrWhiteSpace(codeInput) ? entity.CategoryCode : codeInput; + if (string.IsNullOrWhiteSpace(code)) + { + code = await GenerateUniqueLabelCategoryCodeAsync(); + } + var name = input.CategoryName?.Trim(); - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name)) + if (string.IsNullOrWhiteSpace(name)) { - throw new UserFriendlyException("分类编码和名称不能为空"); + throw new UserFriendlyException("分类名称不能为空"); } var displayText = input.DisplayText?.Trim(); var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); + + if (!string.Equals(code, entity.CategoryCode, StringComparison.Ordinal)) + { + await EnsureLabelCategoryCodeNotDuplicatedAsync(code, id); + await PurgeSoftDeletedLabelCategoriesByCodeAsync(code); + } - var duplicated = await _dbContext.SqlSugarClient.Queryable() - .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.CategoryCode == code || x.CategoryName == name)); - if (duplicated) + if (!string.Equals(name, entity.CategoryName, StringComparison.Ordinal)) { - throw new UserFriendlyException("分类编码或名称已存在"); + await EnsureLabelCategoryNameNotDuplicatedAsync(name, id); } entity.CategoryCode = code; @@ -238,8 +253,9 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ entity.State = input.State; entity.ButtonAppearance = appearance; entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; entity.AppliedPartnerType = partnerScope.AppliedPartnerType; - entity.OrderNum = input.OrderNum; + entity.OrderNum = input.OrderNum ?? entity.OrderNum; entity.LastModificationTime = DateTime.Now; entity.LastModifierId = CurrentUser?.Id?.ToString(); @@ -298,7 +314,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ }; } - private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, List LocationIds)> ResolveCategoryScopeForSaveAsync( + private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, string AppliedRegionType, List LocationIds)> ResolveCategoryScopeForSaveAsync( LabelCategoryCreateInputVo input) { var partnerScope = await LabelEntityPartnerScopeHelper.ResolvePartnerScopeForSaveAsync( @@ -307,10 +323,8 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ input.PartnerIds, input.CompanyIds); - var regionIds = NormalizeRegionIds(input); - var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds); + var mergedRegionIds = NormalizeRegionIds(input); var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null; - var partnerContext = string.Equals( partnerScope.AppliedPartnerType, LabelEntityPartnerScopeHelper.ScopeSpecified, @@ -318,40 +332,15 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ ? partnerScope.PartnerIds : null; - if (await AllScopeBindingHelper.ShouldTreatMergedLocationScopeAsAllAsync( - _dbContext.SqlSugarClient, - input.AvailabilityType, - regionIds, - explicitLocationIds, - hasScopeArrays, - partnerContext)) - { - return (partnerScope, "ALL", new List()); - } - - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant(); - if (regionIds.Count > 0 || explicitLocationIds.Count > 0) - { - availabilityType = "SPECIFIED"; - } - else if (hasScopeArrays && AllScopeBindingHelper.IsDeclaredAll(availabilityType)) - { - availabilityType = "ALL"; - } - - if (availabilityType != "ALL" && availabilityType != "SPECIFIED") - { - throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); - } - - var locationSpecified = string.Equals(availabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase); - var savedLocationIds = await LocationScopeBindingHelper.ResolveEntityLocationIdsForSaveAsync( + var locScope = await AllScopeBindingHelper.ResolveLabelEntityRegionLocationForSaveAsync( _dbContext.SqlSugarClient, - locationSpecified, - regionIds, - explicitLocationIds); + input.AvailabilityType, + partnerContext, + mergedRegionIds, + input.LocationIds, + hasScopeArrays); - return (partnerScope, locationSpecified ? "SPECIFIED" : "ALL", savedLocationIds); + return (partnerScope, locScope.AvailabilityType, locScope.AppliedRegionType, locScope.LocationIds); } private static List NormalizeRegionIds(LabelCategoryCreateInputVo input) @@ -384,6 +373,11 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ query = await LabelEntityPartnerScopeHelper.ApplyCategoryPartnerListFilterAsync( _dbContext.SqlSugarClient, query, scopedPartnerIds); + if (!LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter(partnerId, groupId, locationId)) + { + return query; + } + if (scopedLocationIds is null) { return query; @@ -425,6 +419,55 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ dto.CompanyIds = scope.PartnerIds; } + /// + /// 生成未删除数据中不重复的 LC_ 前缀分类编码。 + /// + private async Task GenerateUniqueLabelCategoryCodeAsync() + { + for (var i = 0; i < 8; i++) + { + var code = $"LC_{YitIdHelper.NextId()}"; + var exists = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.CategoryCode == code); + if (!exists) + { + return code; + } + } + + throw new UserFriendlyException("无法生成唯一分类编码,请稍后重试或手动填写编码"); + } + + private async Task EnsureLabelCategoryCodeNotDuplicatedAsync(string code, string? excludeId = null) + { + var query = _dbContext.SqlSugarClient.Queryable() + .Where(x => !x.IsDeleted && x.CategoryCode == code); + if (!string.IsNullOrWhiteSpace(excludeId)) + { + query = query.Where(x => x.Id != excludeId); + } + + if (await query.AnyAsync()) + { + throw new UserFriendlyException("分类编码已存在"); + } + } + + private async Task EnsureLabelCategoryNameNotDuplicatedAsync(string name, string? excludeId = null) + { + var query = _dbContext.SqlSugarClient.Queryable() + .Where(x => !x.IsDeleted && x.CategoryName == name); + if (!string.IsNullOrWhiteSpace(excludeId)) + { + query = query.Where(x => x.Id != excludeId); + } + + if (await query.AnyAsync()) + { + throw new UserFriendlyException("分类名称已存在"); + } + } + /// 唯一键不区分软删:同编码软删行会挡住新建,创建前物理清理。 private async Task PurgeSoftDeletedLabelCategoriesByCodeAsync(string categoryCode) { @@ -473,8 +516,8 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ { Region = AllRegionsDisplay, Location = AllLocationsDisplay, - RegionIds = new List(), - LocationIds = new List() + RegionIds = new List { AllScopeBindingHelper.ScopeAll }, + LocationIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -580,14 +623,26 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ locationIds, regions, locationNames, - partnerContext); + partnerContext, + entity?.AppliedRegionType); + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + partnerContext, + regionIds, + locationIds, + entity is not null + ? ScopeAllEchoHelper.ForLabelEntityScope( + entity.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType) + : null); result[catId] = new CategoryScopeData { Region = regionDisplay, Location = locationDisplay, - RegionIds = regionIds, - LocationIds = locationIds + RegionIds = collapsed.RegionIds, + LocationIds = collapsed.LocationIds }; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelMultipleOptionAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelMultipleOptionAppService.cs index b7b9b53..83ed804 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelMultipleOptionAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelMultipleOptionAppService.cs @@ -1,9 +1,10 @@ -using FoodLabeling.Application.Helpers; +using FoodLabeling.Application.Helpers; using FoodLabeling.Application.Contracts.Dtos.Common; using FoodLabeling.Application.Contracts.Dtos.LabelMultipleOption; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Domain.Entities; +using FoodLabeling.Domain.Shared.Helpers; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Services; @@ -43,7 +44,12 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO query = await LabelEntityPartnerScopeHelper.ApplyMultipleOptionPartnerListFilterAsync( _dbContext.SqlSugarClient, query, scopedPartnerIds); - query = LabelEntityListScopeHelper.ApplyMultipleOptionLocationAvailabilityFilter(query, scopedLocationIds); + if (LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter( + input.PartnerId, input.GroupId, input.LocationId)) + { + query = LabelEntityListScopeHelper.ApplyMultipleOptionLocationAvailabilityFilter( + query, scopedLocationIds); + } if (!string.IsNullOrWhiteSpace(input.Sorting)) { @@ -55,11 +61,11 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO } var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total); - var scopeMap = await BuildMultipleOptionScopeMapAsync(entities); var partnerScopeMap = await LabelEntityPartnerScopeHelper.BuildPartnerScopeDisplayMapAsync( _dbContext.SqlSugarClient, LabelEntityPartnerScopeHelper.LabelEntityPartnerKind.MultipleOption, entities.Select(x => x.Id).ToList()); + var scopeMap = await BuildMultipleOptionScopeMapAsync(entities, partnerScopeMap); var items = entities.Select(x => { @@ -100,11 +106,7 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO var dto = MapToGetOutput(entity); await ApplyPartnerScopeToGetOutputAsync(dto, entity.Id); - if (string.Equals(entity.AvailabilityType, "ALL", StringComparison.OrdinalIgnoreCase)) - { - await ExpandAllScopeIdsToDtoAsync(dto); - } - else if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) { var locationIds = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.MultipleOptionId == entity.Id) @@ -117,31 +119,52 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO dto.GroupIds = regionIds; } + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + dto.PartnerIds, + dto.RegionIds, + dto.LocationIds, + ScopeAllEchoHelper.ForLabelEntityScope( + dto.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType)); + dto.PartnerIds = collapsed.PartnerIds; + dto.CompanyIds = collapsed.PartnerIds; + dto.RegionIds = collapsed.RegionIds; + dto.GroupIds = collapsed.RegionIds; + dto.LocationIds = collapsed.LocationIds; + return dto; } + /// + /// 新增多选项。Company / Region / Location 范围与编辑一致; + /// regionIdsgroupIdslocationIds 可传哨兵 ALL(大小写不敏感),归档为 availabilityType=ALL 且不写门店快照。 + /// public async Task CreateAsync(LabelMultipleOptionCreateInputVo input) { - var code = NormalizeOptionCode(input.OptionCode); var name = input.OptionName?.Trim(); if (string.IsNullOrWhiteSpace(name)) { throw new UserFriendlyException("多选项名称不能为空"); } - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveMultipleOptionScopeForSaveAsync(input); - - if (await IsMultipleOptionDuplicatedAsync(code, name, excludeId: null)) + var code = input.OptionCode?.Trim(); + if (string.IsNullOrWhiteSpace(code)) { - throw new UserFriendlyException("多选项编码或名称已存在"); + code = await GenerateUniqueOptionCodeAsync(); } - - // 唯一索引含软删行:同编码已删除记录会挡住 INSERT,创建前物理清理 - if (!string.IsNullOrEmpty(code)) + else { - await PurgeSoftDeletedMultipleOptionsByCodeAsync(code); + await EnsureOptionCodeNotDuplicatedAsync(code); } + await EnsureOptionNameNotDuplicatedAsync(name); + + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveMultipleOptionScopeForSaveAsync(input); + + await PurgeSoftDeletedMultipleOptionsByCodeAsync(code); + var now = DateTime.Now; var currentUserId = CurrentUser?.Id?.ToString(); var entity = new FlLabelMultipleOptionDbEntity @@ -158,8 +181,9 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO OptionValuesJson = input.OptionValuesJson?.Trim(), State = input.State, AvailabilityType = availabilityType, + AppliedRegionType = appliedRegionType, AppliedPartnerType = partnerScope.AppliedPartnerType, - OrderNum = input.OrderNum + OrderNum = input.OrderNum ?? 0 }; await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); @@ -168,6 +192,10 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO return await GetAsync(entity.Id); } + /// + /// 编辑多选项。适用范围解析与新增共用 ; + /// regionIdsgroupIdslocationIds 可传 ALL,与 GET 回显 ["ALL"] 对称。 + /// public async Task UpdateAsync(string id, LabelMultipleOptionUpdateInputVo input) { var entity = await _dbContext.SqlSugarClient.Queryable() @@ -177,18 +205,30 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO throw new UserFriendlyException("多选项不存在"); } - var code = NormalizeOptionCode(input.OptionCode); + var codeInput = input.OptionCode?.Trim(); + var code = string.IsNullOrWhiteSpace(codeInput) ? entity.OptionCode : codeInput; + if (string.IsNullOrWhiteSpace(code)) + { + code = await GenerateUniqueOptionCodeAsync(); + } + var name = input.OptionName?.Trim(); if (string.IsNullOrWhiteSpace(name)) { throw new UserFriendlyException("多选项名称不能为空"); } - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveMultipleOptionScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveMultipleOptionScopeForSaveAsync(input); - if (await IsMultipleOptionDuplicatedAsync(code, name, excludeId: id)) + if (!string.Equals(code, entity.OptionCode, StringComparison.Ordinal)) { - throw new UserFriendlyException("多选项编码或名称已存在"); + await EnsureOptionCodeNotDuplicatedAsync(code, id); + await PurgeSoftDeletedMultipleOptionsByCodeAsync(code); + } + + if (!string.Equals(name, entity.OptionName, StringComparison.Ordinal)) + { + await EnsureOptionNameNotDuplicatedAsync(name, id); } entity.OptionCode = code; @@ -196,8 +236,9 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO entity.OptionValuesJson = input.OptionValuesJson?.Trim(); entity.State = input.State; entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; entity.AppliedPartnerType = partnerScope.AppliedPartnerType; - entity.OrderNum = input.OrderNum; + entity.OrderNum = input.OrderNum ?? entity.OrderNum; entity.LastModificationTime = DateTime.Now; entity.LastModifierId = CurrentUser?.Id?.ToString(); @@ -264,25 +305,7 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO } } - /// AvailabilityType=ALL 时详情回填当前可见 Company/Region/Location 全集,便于 Select All 回显。 - private async Task ExpandAllScopeIdsToDtoAsync(LabelMultipleOptionGetOutputDto dto) - { - var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - var (partners, regions, locations) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, dto.PartnerIds); - if (dto.PartnerIds.Count == 0) - { - dto.PartnerIds = partners; - dto.CompanyIds = partners; - } - - dto.RegionIds = regions; - dto.GroupIds = regions; - dto.LocationIds = locations; - } - - private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, List LocationIds)> ResolveMultipleOptionScopeForSaveAsync( + private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, string AppliedRegionType, List LocationIds)> ResolveMultipleOptionScopeForSaveAsync( LabelMultipleOptionCreateInputVo input) { var partnerScope = await LabelEntityPartnerScopeHelper.ResolvePartnerScopeForSaveAsync( @@ -291,10 +314,8 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO input.PartnerIds, input.CompanyIds); - var regionIds = NormalizeRegionIds(input); - var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds); + var mergedRegionIds = NormalizeRegionIds(input); var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null; - var partnerContext = string.Equals( partnerScope.AppliedPartnerType, LabelEntityPartnerScopeHelper.ScopeSpecified, @@ -302,40 +323,15 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO ? partnerScope.PartnerIds : null; - if (await AllScopeBindingHelper.ShouldTreatMergedLocationScopeAsAllAsync( - _dbContext.SqlSugarClient, - input.AvailabilityType, - regionIds, - explicitLocationIds, - hasScopeArrays, - partnerContext)) - { - return (partnerScope, "ALL", new List()); - } - - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant(); - if (regionIds.Count > 0 || explicitLocationIds.Count > 0) - { - availabilityType = "SPECIFIED"; - } - else if (hasScopeArrays && AllScopeBindingHelper.IsDeclaredAll(availabilityType)) - { - availabilityType = "ALL"; - } - - if (availabilityType != "ALL" && availabilityType != "SPECIFIED") - { - throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); - } - - var locationSpecified = string.Equals(availabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase); - var savedLocationIds = await LocationScopeBindingHelper.ResolveEntityLocationIdsForSaveAsync( + var locScope = await AllScopeBindingHelper.ResolveLabelEntityRegionLocationForSaveAsync( _dbContext.SqlSugarClient, - locationSpecified, - regionIds, - explicitLocationIds); + input.AvailabilityType, + partnerContext, + mergedRegionIds, + input.LocationIds, + hasScopeArrays); - return (partnerScope, locationSpecified ? "SPECIFIED" : "ALL", savedLocationIds); + return (partnerScope, locScope.AvailabilityType, locScope.AppliedRegionType, locScope.LocationIds); } private async Task SaveMultipleOptionPartnerScopeAsync( @@ -415,26 +411,56 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync(); } - private static string NormalizeOptionCode(string? optionCode) => optionCode?.Trim() ?? string.Empty; - private static string FormatOptionCodeDisplay(string? optionCode) => string.IsNullOrWhiteSpace(optionCode) ? EmptyDisplay : optionCode.Trim(); - private async Task IsMultipleOptionDuplicatedAsync(string code, string name, string? excludeId) + /// + /// 生成未删除数据中不重复的 OPT_ 前缀多选项编码。 + /// + private async Task GenerateUniqueOptionCodeAsync() + { + for (var i = 0; i < 8; i++) + { + var code = $"OPT_{YitIdHelper.NextId()}"; + var exists = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.OptionCode == code); + if (!exists) + { + return code; + } + } + + throw new UserFriendlyException("无法生成唯一多选项编码,请稍后重试或手动填写编码"); + } + + private async Task EnsureOptionCodeNotDuplicatedAsync(string code, string? excludeId = null) { var query = _dbContext.SqlSugarClient.Queryable() - .Where(x => !x.IsDeleted); + .Where(x => !x.IsDeleted && x.OptionCode == code); if (!string.IsNullOrWhiteSpace(excludeId)) { query = query.Where(x => x.Id != excludeId); } - if (string.IsNullOrEmpty(code)) + if (await query.AnyAsync()) { - return await query.AnyAsync(x => x.OptionName == name); + throw new UserFriendlyException("多选项编码已存在"); } + } - return await query.AnyAsync(x => x.OptionCode == code || x.OptionName == name); + private async Task EnsureOptionNameNotDuplicatedAsync(string name, string? excludeId = null) + { + var query = _dbContext.SqlSugarClient.Queryable() + .Where(x => !x.IsDeleted && x.OptionName == name); + if (!string.IsNullOrWhiteSpace(excludeId)) + { + query = query.Where(x => x.Id != excludeId); + } + + if (await query.AnyAsync()) + { + throw new UserFriendlyException("多选项名称已存在"); + } } private static LabelMultipleOptionGetOutputDto MapToGetOutput(FlLabelMultipleOptionDbEntity x) @@ -452,7 +478,8 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO } private async Task> BuildMultipleOptionScopeMapAsync( - List entities) + List entities, + Dictionary partnerScopeMap) { var result = new Dictionary(StringComparer.Ordinal); if (entities.Count == 0) @@ -460,6 +487,8 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO return result; } + var entityById = entities.ToDictionary(x => x.Id, StringComparer.Ordinal); + foreach (var e in entities.Where(x => !string.Equals(x.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase))) { @@ -467,8 +496,8 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO { Region = AllRegionsDisplay, Location = AllLocationsDisplay, - RegionIds = new List(), - LocationIds = new List() + RegionIds = new List { AllScopeBindingHelper.ScopeAll }, + LocationIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -556,6 +585,28 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); + entityById.TryGetValue(optionId, out var entity); + partnerScopeMap.TryGetValue(optionId, out var partnerScope); + var partnerContext = entity is not null + && string.Equals( + entity.AppliedPartnerType, + LabelEntityPartnerScopeHelper.ScopeSpecified, + StringComparison.OrdinalIgnoreCase) + ? partnerScope?.PartnerIds + : null; + + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + partnerContext, + regionIds, + locationIds, + entity is not null + ? ScopeAllEchoHelper.ForLabelEntityScope( + entity.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType) + : null); + result[optionId] = new MultipleOptionScopeData { Region = regions.Count > 0 @@ -564,8 +615,8 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO Location = locationNames.Count > 0 ? string.Join(", ", locationNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) : EmptyDisplay, - RegionIds = regionIds, - LocationIds = locationIds + RegionIds = collapsed.RegionIds, + LocationIds = collapsed.LocationIds }; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs index 4da6d91..cc70a6c 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs @@ -46,7 +46,12 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ .WhereIF(input.State != null, x => x.State == input.State); query = await LabelTemplateScopeHelper.ApplyTemplateScopeFilterAsync( - _dbContext.SqlSugarClient, query, scopedLocationIds); + _dbContext.SqlSugarClient, + query, + scopedLocationIds, + input.PartnerId, + input.GroupId, + input.LocationId); query = LabelTemplateQueryHelper.ApplyListSorting(query, input.Sorting); query = LabelTemplateQueryHelper.ProjectListColumns(query); @@ -216,47 +221,29 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ await FillTemplateScopeOnDtoAsync(dto, template); - // ALL 维度回填当前可见全集 Id,便于编辑页 Select All(与 Label 一致) - var anyAll = - string.Equals(dto.AppliedPartnerType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase) - || string.Equals(dto.AppliedRegionType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase) - || string.Equals(dto.AppliedLocationType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase); - if (anyAll) - { - var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - var preferredPartners = - string.Equals(dto.AppliedPartnerType, LabelTemplateScopeHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase) - ? dto.PartnerIds - : null; - var (partners, regions, locations) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, preferredPartners); - - if (string.Equals(dto.AppliedPartnerType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase) - && dto.PartnerIds.Count == 0) - { - dto.PartnerIds = partners; - dto.CompanyIds = partners; - } - - if (string.Equals(dto.AppliedRegionType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase) - && dto.RegionIds.Count == 0) - { - dto.RegionIds = regions; - dto.GroupIds = regions; - } - - if (string.Equals(dto.AppliedLocationType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase) - && dto.LocationIds.Count == 0) - { - dto.LocationIds = locations; - dto.AppliedLocationIds = locations; - } - } + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + dto.PartnerIds, + dto.RegionIds, + dto.LocationIds, + ScopeAllEchoHelper.ForLabelTemplateDimensions( + dto.AppliedPartnerType, + dto.AppliedRegionType, + dto.AppliedLocationType)); + dto.PartnerIds = collapsed.PartnerIds; + dto.CompanyIds = collapsed.PartnerIds; + dto.RegionIds = collapsed.RegionIds; + dto.GroupIds = collapsed.RegionIds; + dto.LocationIds = collapsed.LocationIds; + dto.AppliedLocationIds = collapsed.LocationIds; return dto; } + /// + /// 新增标签模板。Company / Region / Location 三维范围; + /// regionIdsgroupIdslocationIdsappliedLocationIds 可传 ALL 哨兵归档为对应维度 ALL。 + /// [UnitOfWork] public async Task CreateAsync(LabelTemplateCreateInputVo input) { @@ -334,6 +321,12 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ return await GetAsync(code); } + /// + /// 编辑标签模板(版本号 +1)。适用范围经 与 + /// 落库(含主表 AppliedLocationType 及 + /// AppliedPartnerType / AppliedRegionType); + /// regionIdsgroupIdslocationIdsappliedLocationIds 可传 ALL,与 GET 回显对称。 + /// [UnitOfWork] public async Task UpdateAsync(string id, LabelTemplateUpdateInputVo input) { diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTypeAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTypeAppService.cs index ceb9990..7fec0e1 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTypeAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTypeAppService.cs @@ -1,9 +1,10 @@ -using FoodLabeling.Application.Helpers; +using FoodLabeling.Application.Helpers; using FoodLabeling.Application.Contracts.Dtos.Common; using FoodLabeling.Application.Contracts.Dtos.LabelType; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Domain.Entities; +using FoodLabeling.Domain.Shared.Helpers; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Services; @@ -43,7 +44,11 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService query = await LabelEntityPartnerScopeHelper.ApplyTypePartnerListFilterAsync( _dbContext.SqlSugarClient, query, scopedPartnerIds); - query = LabelEntityListScopeHelper.ApplyTypeLocationAvailabilityFilter(query, scopedLocationIds); + if (LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter( + input.PartnerId, input.GroupId, input.LocationId)) + { + query = LabelEntityListScopeHelper.ApplyTypeLocationAvailabilityFilter(query, scopedLocationIds); + } if (!string.IsNullOrWhiteSpace(input.Sorting)) { @@ -58,11 +63,11 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService var ids = entities.Select(x => x.Id).ToList(); var countMap = await BuildTypeLabelStatsMapAsync(ids, scopedLocationIds); - var scopeMap = await BuildTypeConfiguredScopeMapAsync(entities); var partnerScopeMap = await LabelEntityPartnerScopeHelper.BuildPartnerScopeDisplayMapAsync( _dbContext.SqlSugarClient, LabelEntityPartnerScopeHelper.LabelEntityPartnerKind.Type, ids); + var scopeMap = await BuildTypeConfiguredScopeMapAsync(entities, partnerScopeMap); var items = entities.Select(x => { @@ -106,11 +111,7 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService var dto = MapToGetOutput(entity); await ApplyPartnerScopeToGetOutputAsync(dto, entity.Id); - if (string.Equals(entity.AvailabilityType, "ALL", StringComparison.OrdinalIgnoreCase)) - { - await ExpandAllScopeIdsToDtoAsync(dto); - } - else if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) { var locationIds = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.LabelTypeId == entity.Id) @@ -123,27 +124,56 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService dto.GroupIds = regionIds; } + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + dto.PartnerIds, + dto.RegionIds, + dto.LocationIds, + ScopeAllEchoHelper.ForLabelEntityScope( + dto.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType)); + dto.PartnerIds = collapsed.PartnerIds; + dto.CompanyIds = collapsed.PartnerIds; + dto.RegionIds = collapsed.RegionIds; + dto.GroupIds = collapsed.RegionIds; + dto.LocationIds = collapsed.LocationIds; + return dto; } public async Task CreateAsync(LabelTypeCreateInputVo input) { - var code = input.TypeCode?.Trim(); var name = input.TypeName?.Trim(); - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name)) + if (string.IsNullOrWhiteSpace(name)) { - throw new UserFriendlyException("类型编码和名称不能为空"); + throw new UserFriendlyException("类型名称不能为空"); } - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveTypeScopeForSaveAsync(input); + var code = input.TypeCode?.Trim(); + if (string.IsNullOrWhiteSpace(code)) + { + code = await GenerateUniqueLabelTypeCodeAsync(); + } + else + { + var codeDuplicated = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.TypeCode == code); + if (codeDuplicated) + { + throw new UserFriendlyException("类型编码已存在"); + } + } - var duplicated = await _dbContext.SqlSugarClient.Queryable() - .AnyAsync(x => !x.IsDeleted && (x.TypeCode == code || x.TypeName == name)); - if (duplicated) + var nameDuplicated = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.TypeName == name); + if (nameDuplicated) { - throw new UserFriendlyException("类型编码或名称已存在"); + throw new UserFriendlyException("类型名称已存在"); } + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveTypeScopeForSaveAsync(input); + // 唯一索引含软删行:同编码已删除记录会挡住 INSERT,创建前物理清理 await PurgeSoftDeletedLabelTypesByCodeAsync(code); @@ -162,8 +192,9 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService TypeName = name, State = input.State, AvailabilityType = availabilityType, + AppliedRegionType = appliedRegionType, AppliedPartnerType = partnerScope.AppliedPartnerType, - OrderNum = input.OrderNum + OrderNum = input.OrderNum ?? 0 }; await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); @@ -181,28 +212,50 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService throw new UserFriendlyException("标签类型不存在"); } - var code = input.TypeCode?.Trim(); var name = input.TypeName?.Trim(); - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name)) + if (string.IsNullOrWhiteSpace(name)) { - throw new UserFriendlyException("类型编码和名称不能为空"); + throw new UserFriendlyException("类型名称不能为空"); } - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveTypeScopeForSaveAsync(input); + var codeInput = input.TypeCode?.Trim(); + var code = string.IsNullOrWhiteSpace(codeInput) ? entity.TypeCode : codeInput; + if (string.IsNullOrWhiteSpace(code)) + { + code = await GenerateUniqueLabelTypeCodeAsync(); + } - var duplicated = await _dbContext.SqlSugarClient.Queryable() - .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.TypeCode == code || x.TypeName == name)); - if (duplicated) + if (!string.Equals(code, entity.TypeCode, StringComparison.Ordinal)) { - throw new UserFriendlyException("类型编码或名称已存在"); + var codeDuplicated = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.Id != id && x.TypeCode == code); + if (codeDuplicated) + { + throw new UserFriendlyException("类型编码已存在"); + } + + await PurgeSoftDeletedLabelTypesByCodeAsync(code); } + if (!string.Equals(name, entity.TypeName, StringComparison.Ordinal)) + { + var nameDuplicated = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.Id != id && x.TypeName == name); + if (nameDuplicated) + { + throw new UserFriendlyException("类型名称已存在"); + } + } + + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveTypeScopeForSaveAsync(input); + entity.TypeCode = code; entity.TypeName = name; entity.State = input.State; entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; entity.AppliedPartnerType = partnerScope.AppliedPartnerType; - entity.OrderNum = input.OrderNum; + entity.OrderNum = input.OrderNum ?? entity.OrderNum; entity.LastModificationTime = DateTime.Now; entity.LastModifierId = CurrentUser?.Id?.ToString(); @@ -249,6 +302,25 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService private const string AllLocationsDisplay = FoodLabelingDisplayConsts.AllLocation; /// + /// 生成未删除数据中不重复的 LT_ 前缀类型编码。 + /// + private async Task GenerateUniqueLabelTypeCodeAsync() + { + for (var i = 0; i < 8; i++) + { + var code = $"LT_{YitIdHelper.NextId()}"; + var exists = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.TypeCode == code); + if (!exists) + { + return code; + } + } + + throw new UserFriendlyException("无法生成唯一类型编码,请稍后重试或手动填写类型编码"); + } + + /// /// 唯一键 UK_fl_label_type_code 不区分软删;同编码软删行会挡住新建,创建前物理删除。 /// private async Task PurgeSoftDeletedLabelTypesByCodeAsync(string typeCode) @@ -276,25 +348,7 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService } } - /// AvailabilityType=ALL 时详情回填当前可见 Company/Region/Location 全集,便于 Select All 回显。 - private async Task ExpandAllScopeIdsToDtoAsync(LabelTypeGetOutputDto dto) - { - var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - var (partners, regions, locations) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, dto.PartnerIds); - if (dto.PartnerIds.Count == 0) - { - dto.PartnerIds = partners; - dto.CompanyIds = partners; - } - - dto.RegionIds = regions; - dto.GroupIds = regions; - dto.LocationIds = locations; - } - - private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, List LocationIds)> ResolveTypeScopeForSaveAsync( + private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, string AppliedRegionType, List LocationIds)> ResolveTypeScopeForSaveAsync( LabelTypeCreateInputVo input) { var partnerScope = await LabelEntityPartnerScopeHelper.ResolvePartnerScopeForSaveAsync( @@ -303,10 +357,8 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService input.PartnerIds, input.CompanyIds); - var regionIds = NormalizeRegionIds(input); - var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds); + var mergedRegionIds = NormalizeRegionIds(input); var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null; - var partnerContext = string.Equals( partnerScope.AppliedPartnerType, LabelEntityPartnerScopeHelper.ScopeSpecified, @@ -314,40 +366,15 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService ? partnerScope.PartnerIds : null; - if (await AllScopeBindingHelper.ShouldTreatMergedLocationScopeAsAllAsync( - _dbContext.SqlSugarClient, - input.AvailabilityType, - regionIds, - explicitLocationIds, - hasScopeArrays, - partnerContext)) - { - return (partnerScope, "ALL", new List()); - } - - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant(); - if (regionIds.Count > 0 || explicitLocationIds.Count > 0) - { - availabilityType = "SPECIFIED"; - } - else if (hasScopeArrays && AllScopeBindingHelper.IsDeclaredAll(availabilityType)) - { - availabilityType = "ALL"; - } - - if (availabilityType != "ALL" && availabilityType != "SPECIFIED") - { - throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); - } - - var locationSpecified = string.Equals(availabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase); - var savedLocationIds = await LocationScopeBindingHelper.ResolveEntityLocationIdsForSaveAsync( + var locScope = await AllScopeBindingHelper.ResolveLabelEntityRegionLocationForSaveAsync( _dbContext.SqlSugarClient, - locationSpecified, - regionIds, - explicitLocationIds); + input.AvailabilityType, + partnerContext, + mergedRegionIds, + input.LocationIds, + hasScopeArrays); - return (partnerScope, locationSpecified ? "SPECIFIED" : "ALL", savedLocationIds); + return (partnerScope, locScope.AvailabilityType, locScope.AppliedRegionType, locScope.LocationIds); } private async Task SaveTypePartnerScopeAsync( @@ -460,7 +487,8 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService } private async Task> BuildTypeConfiguredScopeMapAsync( - List entities) + List entities, + Dictionary partnerScopeMap) { var result = new Dictionary(StringComparer.Ordinal); if (entities.Count == 0) @@ -468,6 +496,8 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService return result; } + var entityById = entities.ToDictionary(x => x.Id, StringComparer.Ordinal); + foreach (var e in entities.Where(x => !string.Equals(x.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase))) { @@ -475,8 +505,8 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService { Region = AllRegionsDisplay, Location = AllLocationsDisplay, - RegionIds = new List(), - LocationIds = new List() + RegionIds = new List { AllScopeBindingHelper.ScopeAll }, + LocationIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -564,6 +594,28 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); + entityById.TryGetValue(typeId, out var entity); + partnerScopeMap.TryGetValue(typeId, out var partnerScope); + var partnerContext = entity is not null + && string.Equals( + entity.AppliedPartnerType, + LabelEntityPartnerScopeHelper.ScopeSpecified, + StringComparison.OrdinalIgnoreCase) + ? partnerScope?.PartnerIds + : null; + + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + partnerContext, + regionIds, + locationIds, + entity is not null + ? ScopeAllEchoHelper.ForLabelEntityScope( + entity.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType) + : null); + result[typeId] = new TypeScopeData { Region = regions.Count > 0 @@ -572,8 +624,8 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService Location = locationNames.Count > 0 ? string.Join(", ", locationNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) : EmptyDisplay, - RegionIds = regionIds, - LocationIds = locationIds + RegionIds = collapsed.RegionIds, + LocationIds = collapsed.LocationIds }; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs index 76542b9..acfac5a 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs @@ -24,20 +24,24 @@ public class LocationAppService : ApplicationService, ILocationAppService private readonly ISqlSugarRepository _locationRepository; private readonly ISqlSugarDbContext _dbContext; private readonly IOptionsSnapshot _batchImportOptions; + private readonly DbConnOptions _dbConnOptions; public LocationAppService( ISqlSugarRepository locationRepository, ISqlSugarDbContext dbContext, - IOptionsSnapshot batchImportOptions) + IOptionsSnapshot batchImportOptions, + IOptions dbConnOptions) { _locationRepository = locationRepository; _dbContext = dbContext; _batchImportOptions = batchImportOptions; + _dbConnOptions = dbConnOptions.Value; } /// public async Task> GetListAsync([FromQuery] LocationGetListInputVo input) { + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Location"); RefAsync total = 0; var query = await BuildFilteredQueryAsync(input); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs index ab9f5fb..927c724 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs @@ -4,6 +4,7 @@ using FoodLabeling.Application.Contracts.Dtos.Partner; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Services.DbModels; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; @@ -25,16 +26,22 @@ public class PartnerAppService : ApplicationService, IPartnerAppService private readonly ISqlSugarDbContext _dbContext; private readonly IGuidGenerator _guidGenerator; + private readonly DbConnOptions _dbConnOptions; - public PartnerAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator) + public PartnerAppService( + ISqlSugarDbContext dbContext, + IGuidGenerator guidGenerator, + IOptions dbConnOptions) { _dbContext = dbContext; _guidGenerator = guidGenerator; + _dbConnOptions = dbConnOptions.Value; } /// public async Task> GetListAsync(PartnerGetListInputVo input) { + EnsureBusinessTenantContext("查询 Company"); RefAsync total = 0; var query = await BuildPartnerListQueryAsync(input); @@ -46,6 +53,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService /// public async Task GetAsync(Guid id) { + EnsureBusinessTenantContext("查询 Company"); if (id == Guid.Empty) { throw new UserFriendlyException("Partner id is required."); @@ -66,6 +74,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService [UnitOfWork] public async Task CreateAsync(PartnerCreateInputVo input) { + EnsureBusinessTenantContext("创建 Company"); var name = input.PartnerName?.Trim(); if (string.IsNullOrWhiteSpace(name)) { @@ -102,6 +111,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService [UnitOfWork] public async Task UpdateAsync(Guid id, PartnerUpdateInputVo input) { + EnsureBusinessTenantContext("更新 Company"); if (id == Guid.Empty) { throw new UserFriendlyException("Partner id is required."); @@ -143,6 +153,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService [UnitOfWork] public async Task DeleteAsync(Guid id) { + EnsureBusinessTenantContext("删除 Company"); if (id == Guid.Empty) { throw new UserFriendlyException("Partner id is required."); @@ -166,6 +177,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService [HttpGet] public async Task ExportPdfAsync([FromQuery] PartnerGetListInputVo input) { + EnsureBusinessTenantContext("导出 Company"); QuestPDF.Settings.License = LicenseType.Community; var count = await (await BuildPartnerListQueryAsync(input)).CountAsync(); @@ -340,6 +352,11 @@ public class PartnerAppService : ApplicationService, IPartnerAppService dto.ZipCode = entity.ZipCode; } + private void EnsureBusinessTenantContext(string operation) + { + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, operation); + } + private static string? TrimToNull(string? value) { var t = value?.Trim(); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs index abe6006..712b34e 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using FoodLabeling.Application.Contracts.Dtos.Common; using FoodLabeling.Application.Contracts.Dtos.Product; using FoodLabeling.Application.Contracts.IServices; @@ -6,6 +6,7 @@ using FoodLabeling.Application.Helpers; using FoodLabeling.Application.Options; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Domain.Entities; +using FoodLabeling.Domain.Shared.Helpers; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using SqlSugar; @@ -117,7 +118,9 @@ public class ProductAppService : ApplicationService, IProductAppService State = x.State, AvailabilityType = x.AvailabilityType, NoOfLabels = countMap.TryGetValue(x.Id, out var count) ? count : 0, - LocationIds = isAllLocations ? new List() : (locationIds ?? new List()), + LocationIds = isAllLocations + ? new List { AllScopeBindingHelper.ScopeAll } + : (locationIds ?? new List()), LocationName = isAllLocations ? AllScopeBindingHelper.AllLocationsDisplay : (string.IsNullOrWhiteSpace(locationName) ? FoodLabelingDisplayConsts.NotAvailable : locationName!) @@ -172,8 +175,12 @@ public class ProductAppService : ApplicationService, IProductAppService { var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( CurrentUser, _dbContext, null, null, null); - (partnerIds, groupIds, locationIds) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, null); + partnerIds = scoped is { Count: > 0 } + ? await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( + _dbContext.SqlSugarClient, scoped) + : new List(); + groupIds = new List { AllScopeBindingHelper.ScopeAll }; + locationIds = new List { AllScopeBindingHelper.ScopeAll }; } else { @@ -181,6 +188,28 @@ public class ProductAppService : ApplicationService, IProductAppService _dbContext.SqlSugarClient, locationIds); groupIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); + (_, groupIds, locationIds) = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + partnerIds, + groupIds, + locationIds, + ScopeAllEchoHelper.ForLabelEntityScope( + appliedPartnerType: null, + appliedRegionType: entity.AppliedRegionType, + availabilityType: entity.AvailabilityType)); + } + + List companyIdsForEcho; + if (isAllLocations) + { + // 产品 Company 不支持 ALL:全部门店时不回显 companyIds 哨兵 + companyIdsForEcho = new List(); + } + else + { + companyIdsForEcho = partnerIds.Count > 0 + ? new List { partnerIds[0] } + : new List(); } return new ProductGetOutputDto @@ -197,9 +226,11 @@ public class ProductAppService : ApplicationService, IProductAppService CategoryPhotoUrl = entity.CategoryPhotoUrl, State = entity.State, AvailabilityType = entity.AvailabilityType, - PartnerId = partnerIds.Count > 0 ? partnerIds[0] : null, + PartnerId = companyIdsForEcho.Count > 0 ? companyIdsForEcho[0] : null, PartnerIds = partnerIds, + CompanyIds = companyIdsForEcho, GroupIds = groupIds, + RegionIds = groupIds, LocationIds = locationIds }; } @@ -242,8 +273,9 @@ public class ProductAppService : ApplicationService, IProductAppService }; ApplyProductAppearanceToEntity(entity, input); - var (availabilityType, locationIds) = await ResolveProductScopeForSaveAsync(input); + var (availabilityType, appliedRegionType, locationIds) = await ResolveProductScopeForSaveAsync(input); entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); @@ -303,8 +335,9 @@ public class ProductAppService : ApplicationService, IProductAppService entity.State = input.State; ApplyProductAppearanceToEntity(entity, input); - var (availabilityType, locationIds) = await ResolveProductScopeForSaveAsync(input); + var (availabilityType, appliedRegionType, locationIds) = await ResolveProductScopeForSaveAsync(input); entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); @@ -477,6 +510,47 @@ public class ProductAppService : ApplicationService, IProductAppService } /// + [HttpPost("product/batch-import-online")] + public async Task BatchImportOnlineAsync( + [FromBody] ProductBatchImportOnlineInputVo input) + { + if (input?.Items is null || input.Items.Count == 0) + { + throw new UserFriendlyException("请至少提交一条导入数据(items 不能为空)"); + } + + var opt = _batchImportOptions.Value; + var maxRows = opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows; + if (input.Items.Count > maxRows) + { + throw new UserFriendlyException($"单次批量导入最多允许 {maxRows} 条,请分批提交"); + } + + var result = new ProductBatchImportOnlineResultDto(); + for (var index = 0; index < input.Items.Count; index++) + { + var vo = input.Items[index]; + try + { + await CreateAsync(vo); + result.SuccessCount++; + } + catch (UserFriendlyException ex) + { + result.FailCount++; + result.Errors.Add(new ProductBatchImportOnlineErrorDto + { + Index = index, + ProductName = vo.ProductName, + Message = ex.Message + }); + } + } + + return result; + } + + /// public async Task UpdateProductsBulkAsync( [FromBody] ProductBulkUpdateInputVo input) { @@ -551,11 +625,27 @@ public class ProductAppService : ApplicationService, IProductAppService input.PartnerId, input.GroupId, input.LocationId); + + var partnerOnly = !LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter( + input.PartnerId, input.GroupId, input.LocationId); + if (locationIds is not null) { if (locationIds.Count == 0) { - query = query.Where(_ => false); + // 仅 PartnerId 且该公司暂无门店:保留 AvailabilityType=ALL + if (partnerOnly) + { + var hasAvailabilityColumn = await ProductScopeSchemaHelper.HasAvailabilityTypeColumnAsync( + _dbContext.SqlSugarClient); + query = hasAvailabilityColumn + ? query.Where(p => p.AvailabilityType == AllScopeBindingHelper.ScopeAll) + : query.Where(_ => false); + } + else + { + query = query.Where(_ => false); + } } else { @@ -598,6 +688,11 @@ public class ProductAppService : ApplicationService, IProductAppService /// private async Task EnsureProductVisibleToCurrentUserAsync(FlProductDbEntity entity) { + if (!CurrentUser.Id.HasValue) + { + throw new UserFriendlyException("登录已过期,请重新登录"); + } + if (ReportsRoleHelper.IsAdminRole(CurrentUser)) { return; @@ -826,7 +921,7 @@ public class ProductAppService : ApplicationService, IProductAppService { for (var i = 0; i < 8; i++) { - var code = $"PRD_{_guidGenerator.Create():N}"; + var code = $"PRD_{YitIdHelper.NextId()}"; var exists = await _dbContext.SqlSugarClient.Queryable() .AnyAsync(x => !x.IsDeleted && x.ProductCode == code); if (!exists) @@ -876,55 +971,103 @@ public class ProductAppService : ApplicationService, IProductAppService private static bool HasProductScopeBinding(ProductCreateInputVo input) => !string.IsNullOrWhiteSpace(input.PartnerId) || + input.PartnerIds is not null || + input.CompanyIds is not null || input.GroupIds is not null || + input.RegionIds is not null || input.LocationIds is not null; private static bool ShouldPersistProductScope(ProductCreateInputVo input) => HasProductScopeBinding(input) || !string.IsNullOrWhiteSpace(input.AvailabilityType); /// - /// 解析产品门店范围:ALL 时不写 fl_location_product 快照,后续新增门店自动可见。 + /// 合并入参 Region(regionIds / groupIds)。 /// - private async Task<(string AvailabilityType, List LocationIds)> ResolveProductScopeForSaveAsync( - ProductCreateInputVo input) + private static List NormalizeProductRegionIds(ProductCreateInputVo input) { - if (!ShouldPersistProductScope(input)) + var merged = new HashSet(StringComparer.Ordinal); + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.RegionIds)) { - return (AllScopeBindingHelper.ScopeSpecified, new List()); + merged.Add(id); } - if (await AllScopeBindingHelper.ShouldTreatProductLocationScopeAsAllAsync( - _dbContext.SqlSugarClient, - input.AvailabilityType, - input.PartnerId, - input.GroupIds, - input.LocationIds, - HasProductScopeBinding(input))) + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.GroupIds)) + { + merged.Add(id); + } + + return merged.OrderBy(x => x, StringComparer.Ordinal).ToList(); + } + + /// + /// 解析产品 Company:仅支持单选具体 Guid;不支持 ALL。合并 partnerIdcompanyIds。 + /// + private static string? ResolveSinglePartnerIdForSave(ProductCreateInputVo input) + { + var merged = new HashSet(StringComparer.Ordinal); + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.CompanyIds)) + { + merged.Add(id); + } + + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.PartnerIds)) + { + merged.Add(id); + } + + var companyIds = merged.OrderBy(x => x, StringComparer.Ordinal).ToList(); + if (AllScopeBindingHelper.HasAllScopeSentinelSelection(companyIds) + || LocationScopeBindingHelper.IsAllScopeSentinel(input.PartnerId)) { - return (AllScopeBindingHelper.ScopeAll, new List()); + throw new UserFriendlyException("产品适用 Company 不支持 ALL,请传单个具体 Company Id"); } - if (!HasProductScopeBinding(input)) + var concreteCompanyIds = LocationScopeBindingHelper.FilterConcreteScopeIds(companyIds); + if (concreteCompanyIds.Count > 1) { - return (AllScopeBindingHelper.ScopeSpecified, new List()); + throw new UserFriendlyException("产品适用 Company 仅支持单选(companyIds 最多传 1 个)"); } - var locIds = await ResolveProductLocationIdsForSaveAsync(input); - return (AllScopeBindingHelper.ScopeSpecified, locIds); + var fromCompanyIds = concreteCompanyIds.Count == 1 ? concreteCompanyIds[0] : null; + var fromPartnerId = input.PartnerId?.Trim(); + if (string.IsNullOrWhiteSpace(fromPartnerId)) + { + return fromCompanyIds; + } + + if (!string.IsNullOrWhiteSpace(fromCompanyIds) + && !string.Equals(fromPartnerId, fromCompanyIds, StringComparison.OrdinalIgnoreCase)) + { + throw new UserFriendlyException("partnerId 与 companyIds 不一致"); + } + + return fromPartnerId; } /// - /// 合并 Company(partnerId)、Region(groupIds)、门店(locationIds)并校验存在性。 + /// 解析产品门店范围:全局 ALL 不写快照;范围内 ALL(已指定 Company/Region)展开为 SPECIFIED 快照。 /// - private async Task> ResolveProductLocationIdsForSaveAsync(ProductCreateInputVo input) + private async Task<(string AvailabilityType, string AppliedRegionType, List LocationIds)> ResolveProductScopeForSaveAsync( + ProductCreateInputVo input) { - var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( + if (!ShouldPersistProductScope(input)) + { + return (AllScopeBindingHelper.ScopeSpecified, AllScopeBindingHelper.ScopeAll, new List()); + } + + var partnerId = ResolveSinglePartnerIdForSave(input); + var mergedRegionIds = NormalizeProductRegionIds(input); + var partnerContext = !string.IsNullOrWhiteSpace(partnerId) ? new[] { partnerId! } : null; + + var locScope = await AllScopeBindingHelper.ResolveLabelEntityRegionLocationForSaveAsync( _dbContext.SqlSugarClient, - input.PartnerId, - input.GroupIds, - input.LocationIds); - await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged); - return merged; + input.AvailabilityType, + partnerContext, + mergedRegionIds, + input.LocationIds, + HasProductScopeBinding(input)); + + return (locScope.AvailabilityType, locScope.AppliedRegionType, locScope.LocationIds); } /// diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs index 9cf00d1..1311db7 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs @@ -1,9 +1,10 @@ -using FoodLabeling.Application.Helpers; +using FoodLabeling.Application.Helpers; using FoodLabeling.Application.Contracts.Dtos.Common; using FoodLabeling.Application.Contracts.Dtos.ProductCategory; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Domain.Entities; +using FoodLabeling.Domain.Shared.Helpers; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Services; @@ -77,11 +78,17 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total); - var scopeMap = await BuildCategoryScopeMapAsync(entities); + var ids = entities.Select(x => x.Id).ToList(); + var partnerScopeMap = await LabelEntityPartnerScopeHelper.BuildPartnerScopeDisplayMapAsync( + _dbContext.SqlSugarClient, + LabelEntityPartnerScopeHelper.LabelEntityPartnerKind.ProductCategory, + ids); + var scopeMap = await BuildCategoryScopeMapAsync(entities, partnerScopeMap); var items = entities.Select(x => { scopeMap.TryGetValue(x.Id, out var scope); + partnerScopeMap.TryGetValue(x.Id, out var partnerScope); return new ProductCategoryGetListOutputDto { Id = x.Id, @@ -92,6 +99,10 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp ButtonAppearance = x.ButtonAppearance, State = x.State, AvailabilityType = x.AvailabilityType, + AppliedPartnerType = partnerScope?.AppliedPartnerType ?? LabelEntityPartnerScopeHelper.ScopeAll, + Company = partnerScope?.Company ?? LabelEntityPartnerScopeHelper.AllCompaniesDisplay, + PartnerIds = partnerScope?.PartnerIds ?? new List(), + CompanyIds = partnerScope?.PartnerIds ?? new List(), OrderNum = x.OrderNum, LastEdited = x.LastModificationTime ?? x.CreationTime, Region = scope?.Region ?? string.Empty, @@ -119,17 +130,8 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp await EnsureCategoryVisibleToCurrentUserAsync(entity); var dto = MapToGetOutput(entity); - if (string.Equals(entity.AvailabilityType, "ALL", StringComparison.OrdinalIgnoreCase)) - { - var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - var (_, regions, locations) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, null); - dto.RegionIds = regions; - dto.GroupIds = regions; - dto.LocationIds = locations; - } - else if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) + await ApplyPartnerScopeToGetOutputAsync(dto, entity.Id); + if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) { var locationIds = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.CategoryId == entity.Id) @@ -142,6 +144,21 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp dto.GroupIds = regionIds; } + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + dto.PartnerIds, + dto.RegionIds, + dto.LocationIds, + ScopeAllEchoHelper.ForLabelEntityScope( + dto.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType)); + dto.PartnerIds = collapsed.PartnerIds; + dto.CompanyIds = collapsed.PartnerIds; + dto.RegionIds = collapsed.RegionIds; + dto.GroupIds = collapsed.RegionIds; + dto.LocationIds = collapsed.LocationIds; + return dto; } @@ -150,18 +167,28 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp /// public async Task CreateAsync(ProductCategoryCreateInputVo input) { - var code = NormalizeCategoryCode(input.CategoryCode); var name = input.CategoryName?.Trim(); if (string.IsNullOrWhiteSpace(name)) { throw new UserFriendlyException("类别名称不能为空"); } + var code = input.CategoryCode?.Trim(); + if (string.IsNullOrWhiteSpace(code)) + { + code = await GenerateUniqueProductCategoryCodeAsync(); + } + else + { + await EnsureCategoryCodeNotDuplicatedAsync(code); + } + + await EnsureCategoryNameNotDuplicatedAsync(name); + var displayText = input.DisplayText?.Trim(); var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); - var (availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); - await EnsureCategoryNotDuplicatedAsync(code, name); await PurgeSoftDeletedProductCategoriesByCodeAsync(code); var now = DateTime.Now; @@ -182,10 +209,13 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp ButtonAppearance = appearance, State = input.State, AvailabilityType = availabilityType, - OrderNum = input.OrderNum + AppliedRegionType = appliedRegionType, + AppliedPartnerType = partnerScope.AppliedPartnerType, + OrderNum = input.OrderNum ?? 0 }; await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); + await SaveCategoryPartnerScopeAsync(entity.Id, partnerScope, currentUserId, now); await SaveCategoryLocationsAsync(entity.Id, availabilityType, mergedLocationIds, currentUserId, now); return await GetAsync(entity.Id); } @@ -204,7 +234,13 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp await EnsureCategoryVisibleToCurrentUserAsync(entity); - var code = NormalizeCategoryCode(input.CategoryCode); + var codeInput = input.CategoryCode?.Trim(); + var code = string.IsNullOrWhiteSpace(codeInput) ? entity.CategoryCode : codeInput; + if (string.IsNullOrWhiteSpace(code)) + { + code = await GenerateUniqueProductCategoryCodeAsync(); + } + var name = input.CategoryName?.Trim(); if (string.IsNullOrWhiteSpace(name)) { @@ -213,9 +249,18 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp var displayText = input.DisplayText?.Trim(); var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); - var (availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); - await EnsureCategoryNotDuplicatedAsync(code, name, id); + if (!string.Equals(code, entity.CategoryCode, StringComparison.Ordinal)) + { + await EnsureCategoryCodeNotDuplicatedAsync(code, id); + await PurgeSoftDeletedProductCategoriesByCodeAsync(code); + } + + if (!string.Equals(name, entity.CategoryName, StringComparison.Ordinal)) + { + await EnsureCategoryNameNotDuplicatedAsync(name, id); + } entity.CategoryCode = code; entity.CategoryName = name; @@ -224,11 +269,15 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp entity.ButtonAppearance = appearance; entity.State = input.State; entity.AvailabilityType = availabilityType; - entity.OrderNum = input.OrderNum; + entity.AppliedRegionType = appliedRegionType; + entity.AppliedPartnerType = partnerScope.AppliedPartnerType; + entity.OrderNum = input.OrderNum ?? entity.OrderNum; entity.LastModificationTime = DateTime.Now; entity.LastModifierId = CurrentUser?.Id?.ToString(); await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); + await SaveCategoryPartnerScopeAsync(entity.Id, partnerScope, entity.LastModifierId, + entity.LastModificationTime ?? DateTime.Now); await SaveCategoryLocationsAsync(entity.Id, availabilityType, mergedLocationIds, entity.LastModifierId, entity.LastModificationTime ?? DateTime.Now); return await GetAsync(id); @@ -274,64 +323,38 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp ButtonAppearance = x.ButtonAppearance, State = x.State, AvailabilityType = x.AvailabilityType, + AppliedPartnerType = x.AppliedPartnerType, OrderNum = x.OrderNum }; } - private async Task<(string AvailabilityType, List LocationIds)> ResolveCategoryScopeForSaveAsync( + private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, string AppliedRegionType, List LocationIds)> ResolveCategoryScopeForSaveAsync( ProductCategoryCreateInputVo input) { - var regionIds = NormalizeRegionIds(input); - var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds); - var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null; - - // 由 Region/门店反推 Company,使「公司下 Select All」可归档为 ALL(勿用全系统门店作全集) - var partnerContext = await AllScopeBindingHelper.ResolvePartnerContextFromScopeAsync( + var partnerScope = await LabelEntityPartnerScopeHelper.ResolvePartnerScopeForSaveAsync( _dbContext.SqlSugarClient, - null, - regionIds, - explicitLocationIds); - - if (await AllScopeBindingHelper.ShouldTreatMergedLocationScopeAsAllAsync( - _dbContext.SqlSugarClient, - input.AvailabilityType, - regionIds, - explicitLocationIds, - hasScopeArrays, - partnerContext)) - { - return ("ALL", new List()); - } + input.AppliedPartnerType, + input.PartnerIds, + input.CompanyIds); - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant(); - if (regionIds.Count > 0 || explicitLocationIds.Count > 0) - { - availabilityType = "SPECIFIED"; - } - else if (hasScopeArrays && AllScopeBindingHelper.IsDeclaredAll(availabilityType)) - { - availabilityType = "ALL"; - } - - if (availabilityType != "ALL" && availabilityType != "SPECIFIED") - { - throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); - } - - if (availabilityType == "ALL") - { - return ("ALL", new List()); - } - - var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( - _dbContext.SqlSugarClient, (IReadOnlyList?)null, regionIds, explicitLocationIds); - if (merged.Count == 0) - { - throw new UserFriendlyException("指定适用区域或门店时,至少需要匹配到一个有效门店"); - } + var mergedRegionIds = NormalizeRegionIds(input); + var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null; + var partnerContext = string.Equals( + partnerScope.AppliedPartnerType, + LabelEntityPartnerScopeHelper.ScopeSpecified, + StringComparison.OrdinalIgnoreCase) + ? partnerScope.PartnerIds + : null; + + var locScope = await AllScopeBindingHelper.ResolveLabelEntityRegionLocationForSaveAsync( + _dbContext.SqlSugarClient, + input.AvailabilityType, + partnerContext, + mergedRegionIds, + input.LocationIds, + hasScopeArrays); - await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged); - return ("SPECIFIED", merged); + return (partnerScope, locScope.AvailabilityType, locScope.AppliedRegionType, locScope.LocationIds); } /// @@ -361,6 +384,18 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp { var scopedLocationIds = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( CurrentUser, _dbContext, partnerId, groupId, locationId); + var scopedPartnerIds = await LabelEntityPartnerScopeHelper.ResolveListPartnerIdsAsync( + _dbContext.SqlSugarClient, partnerId, scopedLocationIds); + + query = await LabelEntityPartnerScopeHelper.ApplyProductCategoryPartnerListFilterAsync( + _dbContext.SqlSugarClient, query, scopedPartnerIds); + + // 仅 PartnerId:只按 Company 维度筛,避免第二家公司无门店时误杀 + if (!LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter(partnerId, groupId, locationId)) + { + return query; + } + if (scopedLocationIds is null) { return query; @@ -368,10 +403,10 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + return query.Where(c => c.AvailabilityType == "ALL"); } - // 非平台管理员:绑定门店与可见范围有交集,或 AvailabilityType=ALL(动态包含后续新增门店) + // 绑定门店与可见范围有交集,或 AvailabilityType=ALL(动态包含后续新增门店) return query.Where(c => c.AvailabilityType == "ALL" || SqlFunc.Subqueryable() @@ -414,6 +449,39 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp } } + private async Task SaveCategoryPartnerScopeAsync( + string categoryId, + LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult partnerScope, + string? currentUserId, + DateTime now) + { + await LabelEntityPartnerScopeHelper.SavePartnerScopeAsync( + _dbContext.SqlSugarClient, + _guidGenerator, + LabelEntityPartnerScopeHelper.LabelEntityPartnerKind.ProductCategory, + categoryId, + partnerScope, + currentUserId, + now); + } + + private async Task ApplyPartnerScopeToGetOutputAsync(ProductCategoryGetOutputDto dto, string entityId) + { + var map = await LabelEntityPartnerScopeHelper.BuildPartnerScopeDisplayMapAsync( + _dbContext.SqlSugarClient, + LabelEntityPartnerScopeHelper.LabelEntityPartnerKind.ProductCategory, + new[] { entityId }); + if (!map.TryGetValue(entityId, out var scope)) + { + return; + } + + dto.AppliedPartnerType = scope.AppliedPartnerType; + dto.Company = scope.Company; + dto.PartnerIds = scope.PartnerIds; + dto.CompanyIds = scope.PartnerIds; + } + private async Task SaveCategoryLocationsAsync( string categoryId, string availabilityType, @@ -455,7 +523,8 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp /// 列表行:Region/Location 展示文案 + 多选 Id 数组(编辑回显)。 /// private async Task> BuildCategoryScopeMapAsync( - List entities) + List entities, + Dictionary partnerScopeMap) { var result = new Dictionary(StringComparer.Ordinal); if (entities.Count == 0) @@ -463,15 +532,16 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp return result; } - foreach (var e in entities.Where(x => - !string.Equals(x.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase))) + var entityById = entities.ToDictionary(x => x.Id, StringComparer.Ordinal); + + foreach (var e in entities.Where(x => AllScopeBindingHelper.IsDeclaredAll(x.AvailabilityType))) { result[e.Id] = new CategoryScopeData { Region = AllRegionsDisplay, Location = AllLocationsDisplay, - RegionIds = new List(), - LocationIds = new List() + RegionIds = new List { AllScopeBindingHelper.ScopeAll }, + LocationIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -560,16 +630,43 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); + entityById.TryGetValue(catId, out var entity); + partnerScopeMap.TryGetValue(catId, out var partnerScope); + var partnerContext = entity is not null + && string.Equals( + entity.AppliedPartnerType, + LabelEntityPartnerScopeHelper.ScopeSpecified, + StringComparison.OrdinalIgnoreCase) + ? partnerScope?.PartnerIds + : null; + + var (regionDisplay, locationDisplay) = await EntityLocationScopeDisplayHelper.BuildListDisplayAsync( + _dbContext.SqlSugarClient, + entity?.AvailabilityType, + regionIds, + locationIds, + regions, + locationNames, + partnerContext, + entity?.AppliedRegionType); + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + partnerContext, + regionIds, + locationIds, + entity is not null + ? ScopeAllEchoHelper.ForLabelEntityScope( + entity.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType) + : null); + result[catId] = new CategoryScopeData { - Region = regions.Count > 0 - ? string.Join(", ", regions.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) - : EmptyDisplay, - Location = locationNames.Count > 0 - ? string.Join(", ", locationNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) - : EmptyDisplay, - RegionIds = regionIds, - LocationIds = locationIds + Region = regionDisplay, + Location = locationDisplay, + RegionIds = collapsed.RegionIds, + LocationIds = collapsed.LocationIds }; } @@ -584,32 +681,52 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp public List LocationIds { get; init; } = new(); } - private static string NormalizeCategoryCode(string? categoryCode) => - string.IsNullOrWhiteSpace(categoryCode) ? string.Empty : categoryCode.Trim(); + /// + /// 生成未删除数据中不重复的 PC_ 前缀类别编码。 + /// + private async Task GenerateUniqueProductCategoryCodeAsync() + { + for (var i = 0; i < 8; i++) + { + var code = $"PC_{YitIdHelper.NextId()}"; + var exists = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.CategoryCode == code); + if (!exists) + { + return code; + } + } + + throw new UserFriendlyException("无法生成唯一类别编码,请稍后重试或手动填写编码"); + } - private async Task EnsureCategoryNotDuplicatedAsync(string code, string name, string? excludeId = null) + private async Task EnsureCategoryCodeNotDuplicatedAsync(string code, string? excludeId = null) { var query = _dbContext.SqlSugarClient.Queryable() - .Where(x => !x.IsDeleted); - + .Where(x => !x.IsDeleted && x.CategoryCode == code); if (!string.IsNullOrEmpty(excludeId)) { query = query.Where(x => x.Id != excludeId); } - if (string.IsNullOrEmpty(code)) + if (await query.AnyAsync()) { - if (await query.AnyAsync(x => x.CategoryName == name)) - { - throw new UserFriendlyException("类别名称已存在"); - } + throw new UserFriendlyException("类别编码已存在"); + } + } - return; + private async Task EnsureCategoryNameNotDuplicatedAsync(string name, string? excludeId = null) + { + var query = _dbContext.SqlSugarClient.Queryable() + .Where(x => !x.IsDeleted && x.CategoryName == name); + if (!string.IsNullOrEmpty(excludeId)) + { + query = query.Where(x => x.Id != excludeId); } - if (await query.AnyAsync(x => x.CategoryCode == code || x.CategoryName == name)) + if (await query.AnyAsync()) { - throw new UserFriendlyException("类别编码或名称已存在"); + throw new UserFriendlyException("类别名称已存在"); } } @@ -631,6 +748,10 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp foreach (var row in softDeleted) { + await LabelEntityPartnerScopeHelper.DeletePartnerScopeRowsAsync( + _dbContext.SqlSugarClient, + LabelEntityPartnerScopeHelper.LabelEntityPartnerKind.ProductCategory, + row.Id); await _dbContext.SqlSugarClient.Deleteable() .Where(x => x.CategoryId == row.Id) .ExecuteCommandAsync(); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductLocationAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductLocationAppService.cs index fab9bac..0bec691 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductLocationAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductLocationAppService.cs @@ -33,40 +33,127 @@ public class ProductLocationAppService : ApplicationService, IProductLocationApp var locationId = input.LocationId?.Trim(); var productId = input.ProductId?.Trim(); + var partnerId = input.PartnerId?.Trim(); - var query = _dbContext.SqlSugarClient - .Queryable((lp, p) => lp.ProductId == p.Id) - .Where((lp, p) => p.IsDeleted == false); + List entities; + var hasAvailabilityColumn = await ProductScopeSchemaHelper.HasAvailabilityTypeColumnAsync( + _dbContext.SqlSugarClient); if (!string.IsNullOrWhiteSpace(locationId)) { - query = query.Where((lp, p) => lp.LocationId == locationId); + // 指定门店:SPECIFIED 关联行 ∪ AvailabilityType=ALL 的产品 + var query = _dbContext.SqlSugarClient + .Queryable() + .LeftJoin((p, lp) => + p.Id == lp.ProductId && lp.LocationId == locationId) + .Where((p, lp) => !p.IsDeleted) + .WhereIF(!string.IsNullOrWhiteSpace(productId), (p, lp) => p.Id == productId); + + if (hasAvailabilityColumn) + { + query = query.Where((p, lp) => + p.AvailabilityType == AllScopeBindingHelper.ScopeAll + || lp.Id != null); + } + else + { + query = query.Where((p, lp) => lp.Id != null); + } + + query = string.IsNullOrWhiteSpace(input.Sorting) + ? query.OrderBy((p, lp) => p.ProductName) + : query.OrderBy(input.Sorting); + + entities = await query + .Select((p, lp) => new ProductLocationGetListOutputDto + { + Id = lp.Id ?? p.Id, + LocationId = locationId, + ProductId = p.Id, + ProductCode = p.ProductCode, + ProductName = p.ProductName, + ProductImageUrl = p.ProductImageUrl, + LocationCode = null, + LocationName = null + }) + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total); } + else if (!string.IsNullOrWhiteSpace(partnerId)) + { + // 仅 PartnerId:该公司下门店关联 ∪ AvailabilityType=ALL + var partnerLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync( + _dbContext.SqlSugarClient, new[] { partnerId }); + + var query = _dbContext.SqlSugarClient + .Queryable() + .LeftJoin((p, lp) => + p.Id == lp.ProductId + && partnerLocationIds.Count > 0 + && partnerLocationIds.Contains(lp.LocationId)) + .Where((p, lp) => !p.IsDeleted) + .WhereIF(!string.IsNullOrWhiteSpace(productId), (p, lp) => p.Id == productId); + + if (hasAvailabilityColumn) + { + query = query.Where((p, lp) => + p.AvailabilityType == AllScopeBindingHelper.ScopeAll + || lp.Id != null); + } + else if (partnerLocationIds.Count == 0) + { + query = query.Where(_ => false); + } + else + { + query = query.Where((p, lp) => lp.Id != null); + } - if (!string.IsNullOrWhiteSpace(productId)) + query = string.IsNullOrWhiteSpace(input.Sorting) + ? query.OrderBy((p, lp) => p.ProductName) + : query.OrderBy(input.Sorting); + + // 按产品去重分页(ALL 产品无关联行时 LocationId 为空串) + entities = await query + .Select((p, lp) => new ProductLocationGetListOutputDto + { + Id = lp.Id ?? p.Id, + LocationId = lp.LocationId ?? string.Empty, + ProductId = p.Id, + ProductCode = p.ProductCode, + ProductName = p.ProductName, + ProductImageUrl = p.ProductImageUrl, + LocationCode = null, + LocationName = null + }) + .Distinct() + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total); + } + else { - query = query.Where((lp, p) => lp.ProductId == productId); + var query = _dbContext.SqlSugarClient + .Queryable((lp, p) => lp.ProductId == p.Id) + .Where((lp, p) => p.IsDeleted == false) + .WhereIF(!string.IsNullOrWhiteSpace(productId), (lp, p) => lp.ProductId == productId); + + query = string.IsNullOrWhiteSpace(input.Sorting) + ? query.OrderBy((lp, p) => p.ProductName) + : query.OrderBy(input.Sorting); + + entities = await query + .Select((lp, p) => new ProductLocationGetListOutputDto + { + Id = lp.Id, + LocationId = lp.LocationId, + ProductId = p.Id, + ProductCode = p.ProductCode, + ProductName = p.ProductName, + ProductImageUrl = p.ProductImageUrl, + LocationCode = null, + LocationName = null + }) + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total); } - // 默认排序 - query = string.IsNullOrWhiteSpace(input.Sorting) - ? query.OrderBy((lp, p) => p.ProductName) - : query.OrderBy(input.Sorting); - - var entities = await query - .Select((lp, p) => new ProductLocationGetListOutputDto - { - Id = lp.Id, - LocationId = lp.LocationId, - ProductId = p.Id, - ProductCode = p.ProductCode, - ProductName = p.ProductName, - ProductImageUrl = p.ProductImageUrl, - LocationCode = null, - LocationName = null - }) - .ToPageListAsync(input.SkipCount, input.MaxResultCount, total); - // 拉取门店信息用于输出 var locationIdSet = entities .Select(x => x.LocationId) @@ -125,13 +212,31 @@ public class ProductLocationAppService : ApplicationService, IProductLocationApp throw new UserFriendlyException("门店Id不能为空"); } - var rows = await _dbContext.SqlSugarClient - .Queryable((lp, p) => lp.ProductId == p.Id) - .Where((lp, p) => lp.LocationId == locationId && !p.IsDeleted) - .Select((lp, p) => new ProductLocationGetListOutputDto + var hasAvailabilityColumn = await ProductScopeSchemaHelper.HasAvailabilityTypeColumnAsync( + _dbContext.SqlSugarClient); + + var query = _dbContext.SqlSugarClient + .Queryable() + .LeftJoin((p, lp) => + p.Id == lp.ProductId && lp.LocationId == locationId) + .Where((p, lp) => !p.IsDeleted); + + if (hasAvailabilityColumn) + { + query = query.Where((p, lp) => + p.AvailabilityType == AllScopeBindingHelper.ScopeAll + || lp.Id != null); + } + else + { + query = query.Where((p, lp) => lp.Id != null); + } + + var rows = await query + .Select((p, lp) => new ProductLocationGetListOutputDto { - Id = lp.Id, - LocationId = lp.LocationId, + Id = lp.Id ?? p.Id, + LocationId = locationId, ProductId = p.Id, ProductCode = p.ProductCode, ProductName = p.ProductName, diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacMenuAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacMenuAppService.cs index 66868ba..eb8f55f 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacMenuAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacMenuAppService.cs @@ -156,14 +156,25 @@ public class RbacMenuAppService : ApplicationService, IRbacMenuAppService } /// - public async Task> GetTreeAsync() + public virtual async Task> GetTreeAsync() { - // 返回所有字段,但过滤逻辑删除数据 - var menus = await _dbContext.SqlSugarClient.Queryable() + var menus = await LoadActiveMenusForTreeAsync(); + return BuildMenuTreeFromEntities(menus); + } + + /// + /// 加载菜单树数据源;泰额版 SaaS 租户内由子类按公司开通菜单过滤。 + /// + protected virtual async Task> LoadActiveMenusForTreeAsync() + { + return await _dbContext.SqlSugarClient.Queryable() .Where(x => x.IsDeleted == false) .OrderBy(x => x.OrderNum, OrderByType.Desc) .ToListAsync(); + } + private static List BuildMenuTreeFromEntities(List menus) + { var nodes = menus.Select(m => new RbacMenuTreeDto { Id = m.Id, @@ -192,7 +203,11 @@ public class RbacMenuAppService : ApplicationService, IRbacMenuAppService Children = new List() }).ToList(); - // TreeHelper 仅支持 Guid Id/ParentId,这里使用字符串 ParentId 自行构建树 + return BuildMenuTree(nodes); + } + + private static List BuildMenuTree(List nodes) + { var nodeById = nodes .Where(x => !string.IsNullOrWhiteSpace(x.Id)) .GroupBy(x => x.Id) diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacRoleAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacRoleAppService.cs index b2d7086..4a36752 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacRoleAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacRoleAppService.cs @@ -78,6 +78,7 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService }).ToList(); await FillAccessPermissionsAsync(items); + await FillMenuPermissionKeysAsync(items); var totalCount = (int)total; var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount; @@ -108,6 +109,7 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService .Select(x => x.MenuId) .ToListAsync(); + var menuIdStrings = menuIds.Select(x => x.ToString()).ToList(); var dto = new RbacRoleGetOutputDto { Id = entity.Id, @@ -118,7 +120,8 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService State = entity.State, OrderNum = entity.OrderNum, AccessPermissionCodes = DeserializeAccessPermissionCodes(entity.AccessPermissionCodesJson), - MenuIds = menuIds.Select(x => x.ToString()).ToList() + MenuIds = menuIdStrings, + MenuPermissionKeys = menuIdStrings }; await FillAccessPermissionsAsync(new List { dto }); return dto; @@ -219,11 +222,12 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService } /// - /// 新增/编辑时按 menuIds 或 accessPermissions 绑定角色菜单(RoleMenu 表)。 + /// 新增/编辑时按 menuIds、menuPermissionKeys 或 accessPermissions 绑定角色菜单(RoleMenu 表)。 /// private async Task ApplyRoleMenuBindingsAsync(Guid roleId, RbacRoleCreateInputVo input) { var hasMenuIds = input.MenuIds is not null; + var hasMenuPermissionKeys = input.MenuPermissionKeys is not null; var hasAccessPermissions = input.AccessPermissions is not null || input.AccessPermissionCodes is not null; if (hasMenuIds && input.MenuIds!.Count > 0) @@ -232,6 +236,18 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService return; } + if (hasMenuPermissionKeys && input.MenuPermissionKeys!.Count > 0) + { + var menuIds = ParseMenuPermissionKeys(input.MenuPermissionKeys); + if (menuIds.Count == 0) + { + throw new UserFriendlyException("menuPermissionKeys 未包含有效的菜单 Id(Guid)"); + } + + await SetRoleMenusAsync(roleId, menuIds); + return; + } + if (hasAccessPermissions) { var permissionCodes = ResolveAccessPermissions(input); @@ -255,10 +271,16 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService if (hasMenuIds && input.MenuIds!.Count == 0) { await SetRoleMenusAsync(roleId, new List()); + return; + } + + if (hasMenuPermissionKeys && input.MenuPermissionKeys!.Count == 0) + { + await SetRoleMenusAsync(roleId, new List()); } } - private async Task SetRoleMenusAsync(Guid roleId, List menuIds) + protected virtual async Task SetRoleMenusAsync(Guid roleId, List menuIds) { var distinct = menuIds?.Distinct().ToList() ?? new List(); await _roleMenuRepository.DeleteAsync(x => x.RoleId == roleId); @@ -329,6 +351,62 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService } } + private async Task FillMenuPermissionKeysAsync(List items) + { + if (items.Count == 0) + { + return; + } + + var roleIds = items.Select(x => x.Id).Distinct().ToList(); + var links = await _roleMenuRepository._DbQueryable + .Where(rm => roleIds.Contains(rm.RoleId)) + .Select(rm => new { rm.RoleId, rm.MenuId }) + .ToListAsync(); + + var menuIdsByRole = roleIds.ToDictionary(id => id, _ => new List()); + foreach (var link in links) + { + if (menuIdsByRole.TryGetValue(link.RoleId, out var list)) + { + list.Add(link.MenuId.ToString()); + } + } + + foreach (var item in items) + { + if (menuIdsByRole.TryGetValue(item.Id, out var keys)) + { + item.MenuPermissionKeys = keys; + } + } + } + + private static List ParseMenuPermissionKeys(IEnumerable? keys) + { + if (keys == null) + { + return new List(); + } + + var result = new List(); + var seen = new HashSet(); + foreach (var raw in keys) + { + if (string.IsNullOrWhiteSpace(raw) || !Guid.TryParse(raw.Trim(), out var menuId)) + { + continue; + } + + if (seen.Add(menuId)) + { + result.Add(menuId); + } + } + + return result; + } + /// /// Role → RoleMenu → Menu.PermissionCode(空则按 Router 推导)汇总 accessPermissions。 /// diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ReportsAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ReportsAppService.cs index 04a8a9d..efcee4b 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ReportsAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ReportsAppService.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Text.Json; using FoodLabeling.Application.Contracts.Dtos.Common; using FoodLabeling.Application.Helpers; @@ -70,7 +70,9 @@ public class ReportsAppService : ApplicationService, IReportsAppService RefAsync total = 0; var query = BuildReportTaskCore(locationIds, viewAllPrints, currentUserIdStr, keyword) - .LeftJoin((t, l, p, lc, pc, loc, tpl) => t.TemplateId == tpl.Id) + .LeftJoin((t, l, p, lc, pc, loc, tpl) => + SqlFunc.MappingColumn(default(bool), + "CONVERT(t.TemplateId USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(tpl.Id USING utf8mb4) COLLATE utf8mb4_general_ci")) .Where((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= rangeStart && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < rangeEndExcl); @@ -188,7 +190,9 @@ public class ReportsAppService : ApplicationService, IReportsAppService var keyword = input.Keyword?.Trim(); var query = BuildReportTaskCore(locationIds, viewAllPrints, currentUserIdStr, keyword) - .LeftJoin((t, l, p, lc, pc, loc, tpl) => t.TemplateId == tpl.Id) + .LeftJoin((t, l, p, lc, pc, loc, tpl) => + SqlFunc.MappingColumn(default(bool), + "CONVERT(t.TemplateId USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(tpl.Id USING utf8mb4) COLLATE utf8mb4_general_ci")) .Where((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= rangeStart && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < rangeEndExcl) @@ -335,7 +339,9 @@ public class ReportsAppService : ApplicationService, IReportsAppService var keyword = input.Keyword?.Trim(); var query = BuildReportTaskCore(locationIds, viewAllPrints, currentUserIdStr, keyword) - .LeftJoin((t, l, p, lc, pc, loc, tpl) => t.TemplateId == tpl.Id) + .LeftJoin((t, l, p, lc, pc, loc, tpl) => + SqlFunc.MappingColumn(default(bool), + "CONVERT(t.TemplateId USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(tpl.Id USING utf8mb4) COLLATE utf8mb4_general_ci")) .Where((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= rangeStart && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < rangeEndExcl); @@ -435,7 +441,9 @@ public class ReportsAppService : ApplicationService, IReportsAppService var groupedRows = await BuildReportTaskCore(locationIds, viewAllPrints, currentUserIdStr, keyword: null, restrictToCreator: false) - .LeftJoin((t, l, p, lc, pc, loc, tpl) => t.TemplateId == tpl.Id) + .LeftJoin((t, l, p, lc, pc, loc, tpl) => + SqlFunc.MappingColumn(default(bool), + "CONVERT(t.TemplateId USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(tpl.Id USING utf8mb4) COLLATE utf8mb4_general_ci")) .Where((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= rangeStart && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < rangeEndExcl) @@ -730,7 +738,9 @@ public class ReportsAppService : ApplicationService, IReportsAppService .LeftJoin((t, l, p, lc) => l.LabelCategoryId == lc.Id) .LeftJoin((t, l, p, lc, pc) => p.CategoryId == pc.Id) .LeftJoin((t, l, p, lc, pc, loc) => - t.LocationId != null && SqlFunc.ToString(loc.Id) == t.LocationId) + t.LocationId != null && + SqlFunc.MappingColumn(default(bool), + "CONVERT(CAST(loc.Id AS CHAR(36)) USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(t.LocationId USING utf8mb4) COLLATE utf8mb4_general_ci")) .Where((t, l, p, lc, pc, loc) => !loc.IsDeleted) .WhereIF(restrictToCreator && !viewAllPrints, (t, l, p, lc, pc, loc) => t.CreatedBy == currentUserIdStr) .WhereIF(locationIds is not null, (t, l, p, lc, pc, loc) => locationIds!.Contains(t.LocationId!)) diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs index 5d90a76..9088abf 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs @@ -34,24 +34,28 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService private readonly ISqlSugarDbContext _dbContext; private readonly IGuidGenerator _guidGenerator; private readonly IOptionsSnapshot _batchImportOptions; + private readonly DbConnOptions _dbConnOptions; public TeamMemberAppService( ISqlSugarRepository userRepository, UserManager userManager, ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator, - IOptionsSnapshot batchImportOptions) + IOptionsSnapshot batchImportOptions, + IOptions dbConnOptions) { _userRepository = userRepository; _userManager = userManager; _dbContext = dbContext; _guidGenerator = guidGenerator; _batchImportOptions = batchImportOptions; + _dbConnOptions = dbConnOptions.Value; } /// public async Task> GetListAsync(TeamMemberGetListInputVo input) { + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Team Member"); var pageIndex = PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount); var pageSize = input.MaxResultCount; RefAsync total = 0; @@ -138,6 +142,18 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService scopeLocationIds = assigned.Select(x => x.Id).ToList(); } + var dim = await LoadTeamMemberScopeAsync(id); + // 库中存展开 Guid;结合 Applied* 维度回显 ["ALL"],与新增传 ALL 对称 + (regionIds, scopeLocationIds, assigned) = + await TeamMemberScopeDisplayHelper.CollapseScopeIdsToAllSentinelForEditAsync( + _dbContext.SqlSugarClient, + partnerIds, + regionIds, + scopeLocationIds, + assigned, + dim.AppliedRegionType, + dim.AppliedLocationType); + return new TeamMemberGetOutputDto { Id = user.Id, @@ -155,10 +171,66 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService }; } - /// + /// + /// 新增成员并写入 userlocation 门店绑定。 + /// + /// + /// 范围传参支持 ALL 哨兵(大小写不敏感),与具体 Guid 同传时以 ALL 为准(全选)。 + /// + /// 示例请求(Locations 全选): + /// ```json + /// { + /// "fullName": "Jane Doe", + /// "userName": "jane@example.com", + /// "password": "Pass123!", + /// "roleId": "ROLE_GUID", + /// "partnerId": "PARTNER_GUID", + /// "locationIds": ["ALL"], + /// "state": true + /// } + /// ``` + /// + /// 示例请求(Region 全选): + /// ```json + /// { + /// "fullName": "John Doe", + /// "userName": "john@example.com", + /// "password": "Pass123!", + /// "roleId": "ROLE_GUID", + /// "partnerId": "PARTNER_GUID", + /// "regionIds": ["ALL"], + /// "state": true + /// } + /// ``` + /// + /// 参数说明: + /// - partnerId / partnerIds: 展开 ALL 时的 Company 上下文(必填) + /// - locationIds / locations: 可含 ALL;按该公司全部门店落库;列表 assignedLocations 展示 All Location + /// - regionIds / groupIds: 可含 ALL;为 ALL 且同时有具体 locationIds 时以门店为准;含具体 Guid 时按 Region 展开(忽略同传 locationIds) + /// - 优先级:location ALL →(具体 location 且 region 空/ALL)→ region ALL → 具体 region → 具体 location → 仅 Company + /// + /// 示例请求(编辑多 Region + 同传 location,按 Region 展开): + /// ```json + /// { + /// "fullName": "Jane Doe", + /// "userName": "jane@example.com", + /// "roleId": "ROLE_GUID", + /// "partnerId": "PARTNER_GUID", + /// "regionIds": ["REGION_GUID_1", "REGION_GUID_2"], + /// "locationIds": ["LOCATION_GUID"], + /// "state": true + /// } + /// ``` + /// 落库为两 Region 下门店并集;Get 回显 regionIds 含 2 个 Region。 + /// + /// 新增成员请求体 + /// 创建后的成员详情(含 assignedLocations 展示文案) + /// 创建成功 + /// 参数不合法或范围无法解析(如 ALL 未传 Company) + /// 服务器错误 public async Task CreateAsync(TeamMemberCreateInputVo input) { - var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input, input.RoleId); + var scope = await ResolveTeamMemberScopeForSaveAsync(input, input.RoleId); var user = new UserAggregateRoot { @@ -175,21 +247,75 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService user.BuildPassword(); await _userManager.CreateAsync(user); + await UserPasswordHelper.EnsurePasswordColumnsPersistedAsync( + _userRepository, + user.Id, + user.EncryPassword.Password, + user.EncryPassword.Salt); if (input.RoleId != null) { await _userManager.GiveUserSetRoleAsync(new List { user.Id }, new List { input.RoleId.Value }); } - await UpsertUserLocationsAsync(user.Id, mergedLocationIds); + await UpsertUserLocationsAsync(user.Id, scope.LocationIds); + await UpsertTeamMemberScopeAsync(user.Id, scope.AppliedRegionType, scope.AppliedLocationType); return await GetAsync(user.Id); } - /// + /// + /// 更新成员信息与 userlocation 门店绑定。 + /// + /// + /// 范围传参与 一致:locationIds / regionIds / groupIds / locations 可含 ALL 哨兵; + /// 与具体 Guid 同传时以 ALL 为准。含具体 regionIds 时按 Region 展开落库并忽略同传 locationIds。 + /// 覆盖 Company 全部门店时列表/详情 assignedLocations 展示 All Location。 + /// + /// 示例请求(Locations ALL): + /// ```json + /// { + /// "fullName": "Jane Doe", + /// "userName": "jane@example.com", + /// "roleId": "ROLE_GUID", + /// "partnerId": "PARTNER_GUID", + /// "locationIds": ["ALL"], + /// "state": true + /// } + /// ``` + /// + /// 示例请求(多 Region + 同传 location,按 Region 展开): + /// ```json + /// { + /// "fullName": "Jane Doe", + /// "userName": "jane@example.com", + /// "roleId": "ROLE_GUID", + /// "partnerId": "PARTNER_GUID", + /// "regionIds": ["REGION_GUID_1", "REGION_GUID_2"], + /// "locationIds": ["LOCATION_GUID"], + /// "state": true + /// } + /// ``` + /// + /// 成员主键 + /// 更新请求体 + /// 更新后的成员详情 + /// 更新成功 + /// 参数不合法或成员不存在 + /// 服务器错误 public async Task UpdateAsync(Guid id, TeamMemberUpdateInputVo input) { - var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input); + var scope = await ResolveTeamMemberScopeForSaveAsync( + new TeamMemberCreateInputVo + { + PartnerId = input.PartnerId, + PartnerIds = input.PartnerIds, + RegionIds = input.RegionIds, + GroupIds = input.GroupIds, + LocationIds = input.LocationIds, + Locations = input.Locations + }, + input.RoleId); var user = await _userRepository.GetByIdAsync(id); if (user is null || user.IsDeleted) @@ -229,7 +355,8 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService await _userManager.GiveUserSetRoleAsync(new List { id }, new List()); } - await UpsertUserLocationsAsync(id, mergedLocationIds); + await UpsertUserLocationsAsync(id, scope.LocationIds); + await UpsertTeamMemberScopeAsync(id, scope.AppliedRegionType, scope.AppliedLocationType); return await GetAsync(id); } @@ -257,6 +384,10 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService }) .Where(x => x.UserId == userIdString && !x.IsDeleted) .ExecuteCommandAsync(); + + await _dbContext.SqlSugarClient.Deleteable() + .Where(x => x.UserId == userIdString) + .ExecuteCommandAsync(); } /// @@ -449,6 +580,59 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService } /// + [HttpPost("team-member/batch-import-online")] + public async Task BatchImportOnlineAsync( + [FromBody] TeamMemberBatchImportOnlineInputVo input) + { + if (input?.Items is null || input.Items.Count == 0) + { + throw new UserFriendlyException("请至少提交一条导入数据(items 不能为空)"); + } + + var opt = _batchImportOptions.Value; + var maxRows = opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows; + if (input.Items.Count > maxRows) + { + throw new UserFriendlyException($"单次批量导入最多允许 {maxRows} 条,请分批提交"); + } + + var defaultPassword = opt.TeamMemberImportDefaultPassword?.Trim() ?? string.Empty; + var result = new TeamMemberBatchImportOnlineResultDto(); + for (var index = 0; index < input.Items.Count; index++) + { + var vo = input.Items[index]; + try + { + if (string.IsNullOrWhiteSpace(vo.Password)) + { + if (string.IsNullOrEmpty(defaultPassword)) + { + throw new UserFriendlyException( + "未配置默认导入密码 FoodLabeling:BatchImport:TeamMemberImportDefaultPassword"); + } + + vo.Password = defaultPassword; + } + + await CreateAsync(vo); + result.SuccessCount++; + } + catch (UserFriendlyException ex) + { + result.FailCount++; + result.Errors.Add(new TeamMemberBatchImportOnlineErrorDto + { + Index = index, + UserName = vo.UserName, + Message = ex.Message + }); + } + } + + return result; + } + + /// public async Task UpdateTeamMembersBulkAsync( [FromBody] TeamMemberBulkUpdateInputVo input) { @@ -771,6 +955,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService }).Where(x => x != null).Cast().ToList()); var scopeIdsMap = await BuildTeamMemberScopeIdsMapAsync(assignedMap, roleIdByUser); + var dimMap = await LoadTeamMemberScopeMapAsync(users.Select(u => u.Id)); var items = new List(); foreach (var u in users) @@ -780,6 +965,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService var userKey = TeamMemberListScopeHelper.UserKey(u.Id); assignedMap.TryGetValue(userKey, out var assigned); scopeIdsMap.TryGetValue(userKey, out var scopeIds); + dimMap.TryGetValue(userKey, out var dim); var partnerIds = scopeIds?.PartnerIds ?? new List(); var regionIds = scopeIds?.RegionIds ?? new List(); @@ -793,7 +979,8 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService listRoleId, partnerIds, regionIds, - assignedLocations); + assignedLocations, + dim?.AppliedLocationType); var locationIdList = assignedLocations .Select(x => x.Id) @@ -801,6 +988,28 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); + // 与详情一致:全选时 regionIds/locationIds 回显 ["ALL"] + var rawLocationIds = (assigned ?? new List()) + .Select(x => x.Id) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (rawLocationIds.Count == 0) + { + rawLocationIds = locationIdList; + } + + (regionIds, locationIdList, assignedLocations) = + await TeamMemberScopeDisplayHelper.CollapseScopeIdsToAllSentinelForEditAsync( + _dbContext.SqlSugarClient, + partnerIds, + regionIds, + rawLocationIds, + assignedLocations, + dim?.AppliedRegionType, + dim?.AppliedLocationType); + items.Add(new TeamMemberGetListOutputDto { Id = u.Id, @@ -844,19 +1053,6 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService : await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); - if (Guid.TryParse(userId, out var userGuid) && - roleIdByUser.TryGetValue(userGuid, out var roleId) && - await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(_dbContext.SqlSugarClient, roleId) && - partnerIds.Count > 0) - { - var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync( - _dbContext.SqlSugarClient, partnerIds); - if (allRegions.Count > 0) - { - regionIds = allRegions; - } - } - result[userId] = new TeamMemberScopeIds { PartnerIds = partnerIds, @@ -868,7 +1064,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService } /// - /// Company Admin 列表/详情:展示所选 Company 下全部 Region 与门店。 + /// 绑定覆盖 Company 下全部门店时,列表/详情展开为全部 Region 并折叠 Location 为 All Location 展示。 /// private async Task<(List RegionIds, List AssignedLocations)> ApplyCompanyAdminDisplayScopeAsync( @@ -877,16 +1073,32 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService List regionIds, List assigned) { - if (!await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(_dbContext.SqlSugarClient, roleId) || - partnerIds.Count == 0) + if (partnerIds.Count == 0) + { + return (regionIds, assigned); + } + + var assignedIds = assigned + .Select(x => x.Id) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (assignedIds.Count == 0) { return (regionIds, assigned); } - var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync( - _dbContext.SqlSugarClient, partnerIds); var allLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync( _dbContext.SqlSugarClient, partnerIds); + if (allLocationIds.Count == 0 || + !AllScopeBindingHelper.IsFullIdSelection(assignedIds, allLocationIds)) + { + return (regionIds, assigned); + } + + var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync( + _dbContext.SqlSugarClient, partnerIds); var allAssigned = await BuildAssignedLocationDtosAsync(allLocationIds); return ( @@ -935,28 +1147,64 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService public List RegionIds { get; init; } = new(); } - private Task> ResolveTeamMemberLocationIdsForSaveAsync(TeamMemberUpdateInputVo input) => - ResolveTeamMemberLocationIdsForSaveAsync(new TeamMemberCreateInputVo - { - PartnerId = input.PartnerId, - PartnerIds = input.PartnerIds, - RegionIds = input.RegionIds, - GroupIds = input.GroupIds, - LocationIds = input.LocationIds - }, input.RoleId); + private sealed class TeamMemberScopeSaveResult + { + public string AppliedRegionType { get; init; } = AllScopeBindingHelper.ScopeSpecified; + public string AppliedLocationType { get; init; } = AllScopeBindingHelper.ScopeSpecified; + public List LocationIds { get; init; } = new(); + } + + private sealed class TeamMemberScopeDimension + { + public string? AppliedRegionType { get; init; } + public string? AppliedLocationType { get; init; } + } - private async Task> ResolveTeamMemberLocationIdsForSaveAsync( + private async Task ResolveTeamMemberScopeForSaveAsync( TeamMemberCreateInputVo input, Guid? roleId) { var partnerIds = NormalizePartnerIds(input); var regionIds = NormalizeRegionIds(input); - var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds); + var mergedLocationInputs = MergeLocationScopeInputs(input); + var locationHasAllSentinel = LocationScopeBindingHelper.ContainsAllScopeSentinel(mergedLocationInputs); + var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedLocationInputs); + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + var regionHasAllSentinel = LocationScopeBindingHelper.ContainsAllScopeSentinel(regionIds); var isCompanyAdmin = await TeamMemberRoleHelper.IsCompanyAdminRoleAsync( _dbContext.SqlSugarClient, roleId); + var regionHasAll = regionHasAllSentinel; + if (!regionHasAll && concreteRegions.Count > 0 && partnerIds.Count > 0) + { + var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync( + _dbContext.SqlSugarClient, partnerIds); + regionHasAll = allRegions.Count > 0 + && AllScopeBindingHelper.IsFullIdSelection(concreteRegions, allRegions); + } + + var locationCoversConcreteRegions = false; + if (!locationHasAllSentinel && concreteRegions.Count > 0 && !regionHasAll) + { + if (explicitLocationIds.Count == 0) + { + locationCoversConcreteRegions = true; + } + else + { + var regionUniverse = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( + _dbContext.SqlSugarClient, partnerIds, concreteRegions); + locationCoversConcreteRegions = regionUniverse.Count > 0 + && AllScopeBindingHelper.IsFullIdSelection( + explicitLocationIds, regionUniverse); + } + } + + var locationHasAll = locationHasAllSentinel || locationCoversConcreteRegions; + if (isCompanyAdmin && partnerIds.Count > 0 && - regionIds.Count == 0 && explicitLocationIds.Count == 0) + !regionHasAll && concreteRegions.Count == 0 && + !locationHasAll && explicitLocationIds.Count == 0) { var fromPartner = await LocationScopeBindingHelper.MergeToLocationIdsAsync( _dbContext.SqlSugarClient, partnerIds, null, null); @@ -966,11 +1214,30 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService } await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, fromPartner); - return fromPartner; + return new TeamMemberScopeSaveResult + { + AppliedRegionType = AllScopeBindingHelper.ScopeAll, + AppliedLocationType = AllScopeBindingHelper.ScopeAll, + LocationIds = fromPartner + }; + } + + // 传给落库解析:Location 区内全选时用 ALL 哨兵,避免被当成「部分门店」 + // 用户已传具体 locationIds 时保留原值,不替换为 ALL 哨兵 + IReadOnlyList? locationInputForResolve = mergedLocationInputs; + if (locationCoversConcreteRegions && !locationHasAllSentinel && explicitLocationIds.Count == 0) + { + locationInputForResolve = new List { AllScopeBindingHelper.ScopeAll }; } - var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( - _dbContext.SqlSugarClient, partnerIds, regionIds, input.LocationIds); + IReadOnlyList? regionInputForResolve = regionIds; + if (regionHasAll && !regionHasAllSentinel) + { + regionInputForResolve = new List { AllScopeBindingHelper.ScopeAll }; + } + + var merged = await LocationScopeBindingHelper.ResolveTeamMemberLocationIdsForSaveAsync( + _dbContext.SqlSugarClient, partnerIds, regionInputForResolve, locationInputForResolve); if (merged.Count == 0) { @@ -981,7 +1248,136 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService } await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged); - return merged; + + // 用户显式传具体门店 Id 时一律 SPECIFIED,不因「恰好覆盖 Region 全集」归档 ALL + var userExplicitLocations = explicitLocationIds.Count > 0 && !locationHasAllSentinel; + + string appliedRegionType; + string appliedLocationType; + if (userExplicitLocations) + { + appliedRegionType = regionHasAll + ? AllScopeBindingHelper.ScopeAll + : concreteRegions.Count > 0 + ? AllScopeBindingHelper.ScopeSpecified + : AllScopeBindingHelper.ScopeAll; + appliedLocationType = AllScopeBindingHelper.ScopeSpecified; + } + else if (locationHasAll && (regionHasAll || concreteRegions.Count == 0)) + { + appliedRegionType = AllScopeBindingHelper.ScopeAll; + appliedLocationType = AllScopeBindingHelper.ScopeAll; + } + else if (locationHasAll) + { + appliedRegionType = AllScopeBindingHelper.ScopeSpecified; + appliedLocationType = AllScopeBindingHelper.ScopeAll; + } + else if (regionHasAll) + { + appliedRegionType = AllScopeBindingHelper.ScopeAll; + appliedLocationType = AllScopeBindingHelper.ScopeSpecified; + } + else + { + appliedRegionType = concreteRegions.Count > 0 + ? AllScopeBindingHelper.ScopeSpecified + : AllScopeBindingHelper.ScopeAll; + appliedLocationType = AllScopeBindingHelper.ScopeSpecified; + } + + return new TeamMemberScopeSaveResult + { + AppliedRegionType = appliedRegionType, + AppliedLocationType = appliedLocationType, + LocationIds = merged + }; + } + + private async Task LoadTeamMemberScopeAsync(Guid userId) + { + var row = await _dbContext.SqlSugarClient.Queryable() + .FirstAsync(x => x.UserId == userId.ToString()); + if (row is null) + { + return new TeamMemberScopeDimension(); + } + + return new TeamMemberScopeDimension + { + AppliedRegionType = row.AppliedRegionType, + AppliedLocationType = row.AppliedLocationType + }; + } + + private async Task UpsertTeamMemberScopeAsync( + Guid userId, + string appliedRegionType, + string appliedLocationType) + { + var userIdString = userId.ToString(); + var now = DateTime.Now; + var existing = await _dbContext.SqlSugarClient.Queryable() + .FirstAsync(x => x.UserId == userIdString); + if (existing is null) + { + await _dbContext.SqlSugarClient.Insertable(new FlTeamMemberScopeDbEntity + { + UserId = userIdString, + AppliedRegionType = appliedRegionType, + AppliedLocationType = appliedLocationType, + CreationTime = now, + LastModificationTime = now + }).ExecuteCommandAsync(); + return; + } + + existing.AppliedRegionType = appliedRegionType; + existing.AppliedLocationType = appliedLocationType; + existing.LastModificationTime = now; + await _dbContext.SqlSugarClient.Updateable(existing).ExecuteCommandAsync(); + } + + private async Task> LoadTeamMemberScopeMapAsync( + IEnumerable userIds) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var idStrings = userIds.Select(x => x.ToString()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (idStrings.Count == 0) + { + return result; + } + + var rows = await _dbContext.SqlSugarClient.Queryable() + .Where(x => idStrings.Contains(x.UserId)) + .ToListAsync(); + foreach (var row in rows) + { + result[TeamMemberListScopeHelper.NormalizeScopeKey(row.UserId)] = new TeamMemberScopeDimension + { + AppliedRegionType = row.AppliedRegionType, + AppliedLocationType = row.AppliedLocationType + }; + } + + return result; + } + + /// 合并 (均可含 ALL)。 + private static List? MergeLocationScopeInputs(TeamMemberCreateInputVo input) + { + var merged = new List(); + if (input.LocationIds is { Count: > 0 }) + { + merged.AddRange(input.LocationIds); + } + + if (input.Locations is { Count: > 0 }) + { + merged.AddRange(input.Locations); + } + + return merged.Count > 0 ? merged : null; } private static List NormalizePartnerIds(TeamMemberCreateInputVo input) @@ -1020,7 +1416,11 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService { var now = DateTime.Now; var userIdString = userId.ToString(); - var wanted = locationIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToList(); + var wanted = locationIds + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); var currentUserId = CurrentUser?.Id?.ToString(); var validCount = await _dbContext.SqlSugarClient.Queryable() @@ -1032,17 +1432,24 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService throw new UserFriendlyException("存在无效门店,请刷新后重试"); } + // 含已逻辑删除行:唯一键 UK_userlocation_user_location 覆盖全历史,不能对已删行再 INSERT var existing = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.UserId == userIdString) .ToListAsync(); - var existingActive = existing.Where(x => !x.IsDeleted).ToList(); - var existingActiveSet = existingActive.Select(x => x.LocationId).ToHashSet(); + var byLocation = existing + .GroupBy(x => (x.LocationId ?? string.Empty).Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderBy(x => x.IsDeleted).ThenByDescending(x => x.CreationTime).First(), StringComparer.OrdinalIgnoreCase); + + var wantedSet = wanted.ToHashSet(StringComparer.OrdinalIgnoreCase); - var toDelete = existingActive.Where(x => !wanted.Contains(x.LocationId)).ToList(); - if (toDelete.Count > 0) + var toSoftDelete = existing + .Where(x => !x.IsDeleted && !wantedSet.Contains((x.LocationId ?? string.Empty).Trim())) + .Select(x => x.Id) + .Distinct() + .ToList(); + if (toSoftDelete.Count > 0) { - var ids = toDelete.Select(x => x.Id).ToList(); await _dbContext.SqlSugarClient.Updateable() .SetColumns(x => new UserLocationDbEntity { @@ -1050,14 +1457,25 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService LastModificationTime = now, LastModifierId = currentUserId }) - .Where(x => ids.Contains(x.Id)) + .Where(x => toSoftDelete.Contains(x.Id)) .ExecuteCommandAsync(); } - var toInsert = wanted.Where(x => !existingActiveSet.Contains(x)).ToList(); - if (toInsert.Count > 0) + var toReviveIds = new List(); + var toInsert = new List(); + foreach (var locationId in wanted) { - var rows = toInsert.Select(locationId => new UserLocationDbEntity + if (byLocation.TryGetValue(locationId, out var row)) + { + if (row.IsDeleted) + { + toReviveIds.Add(row.Id); + } + + continue; + } + + toInsert.Add(new UserLocationDbEntity { Id = _guidGenerator.Create().ToString(), IsDeleted = false, @@ -1066,9 +1484,25 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService UserId = userIdString, LocationId = locationId, ConcurrencyStamp = string.Empty - }).ToList(); + }); + } - await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync(); + if (toReviveIds.Count > 0) + { + await _dbContext.SqlSugarClient.Updateable() + .SetColumns(x => new UserLocationDbEntity + { + IsDeleted = false, + LastModificationTime = now, + LastModifierId = currentUserId + }) + .Where(x => toReviveIds.Contains(x.Id)) + .ExecuteCommandAsync(); + } + + if (toInsert.Count > 0) + { + await _dbContext.SqlSugarClient.Insertable(toInsert).ExecuteCommandAsync(); } } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TrainingAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TrainingAppService.cs new file mode 100644 index 0000000..c0b2ada --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TrainingAppService.cs @@ -0,0 +1,806 @@ +using FoodLabeling.Application.Contracts.Dtos.Common; +using FoodLabeling.Application.Contracts.Dtos.Training; +using FoodLabeling.Application.Contracts.IServices; +using FoodLabeling.Application.Helpers; +using FoodLabeling.Application.Services.DbModels; +using FoodLabeling.Domain.Shared.Enums; +using FoodLabeling.Domain.Shared.Helpers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; +using SqlSugar; +using Volo.Abp; +using Volo.Abp.Application.Services; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Application.Services; + +/// +/// 培训 / 资料中心(管理端) +/// +public class TrainingAppService : ApplicationService, ITrainingAppService +{ + private const long MaxFileSizeBytes = 20 * 1024 * 1024; + + private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" + }; + + private static readonly HashSet DocExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".csv" + }; + + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase); + private readonly ISqlSugarDbContext _dbContext; + private readonly IHostEnvironment _hostEnvironment; + + static TrainingAppService() + { + foreach (var ext in ImageExtensions) + { + AllowedExtensions.Add(ext); + } + + foreach (var ext in DocExtensions) + { + AllowedExtensions.Add(ext); + } + } + + public TrainingAppService(ISqlSugarDbContext dbContext, IHostEnvironment hostEnvironment) + { + _dbContext = dbContext; + _hostEnvironment = hostEnvironment; + } + + /// + /// 获取培训分类树(可选含文件;支持 keyword、locationId 筛选) + /// + /// + /// 一级分类 ParentId 为空;二级分类 ParentId 指向一级。文件仅挂在二级分类下。 + /// + /// 示例请求: + /// ```json + /// { + /// "keyword": "安全", + /// "locationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + /// "includeFiles": true + /// } + /// ``` + /// + /// 参数说明: + /// - keyword: 匹配分类名或文件名 + /// - locationId: 按门店权限过滤可见文件 + /// - includeFiles: 是否返回文件列表 + /// + /// 查询条件 + /// 分类树 + /// 成功返回分类树 + /// 参数无效 + /// 服务器错误 + public async Task> GetCategoryTreeAsync([FromQuery] TrainingCategoryTreeInputVo input) + { + var keyword = input.Keyword?.Trim(); + TrainingFileScopeHelper.LocationScopeContext? scopeContext = null; + if (!string.IsNullOrWhiteSpace(input.LocationId)) + { + scopeContext = await TrainingFileScopeHelper.ResolveLocationScopeContextAsync( + _dbContext.SqlSugarClient, + input.LocationId); + } + + var categories = await _dbContext.SqlSugarClient.Queryable() + .Where(x => !x.IsDeleted) + .OrderByDescending(x => x.OrderNum) + .OrderByDescending(x => x.CreationTime) + .ToListAsync(); + + var level2Ids = categories + .Where(x => !string.IsNullOrWhiteSpace(x.ParentId)) + .Select(x => x.Id) + .ToList(); + + var filesByCategory = new Dictionary>(StringComparer.Ordinal); + if (input.IncludeFiles && level2Ids.Count > 0) + { + var fileQuery = _dbContext.SqlSugarClient.Queryable() + .Where(x => !x.IsDeleted && level2Ids.Contains(x.CategoryId)); + + fileQuery = TrainingFileScopeHelper.ApplyLocationVisibilityFilter(fileQuery, scopeContext); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + fileQuery = fileQuery.Where(x => x.FileName.Contains(keyword!)); + } + + var files = await fileQuery + .OrderByDescending(x => x.OrderNum) + .OrderByDescending(x => x.CreationTime) + .ToListAsync(); + + foreach (var group in files.GroupBy(x => x.CategoryId)) + { + filesByCategory[group.Key] = group.ToList(); + } + } + + var allFileEntities = filesByCategory.Values.SelectMany(x => x).ToList(); + var scopeDisplayMap = await TrainingFileScopeHelper.BuildScopeDisplayMapAsync( + _dbContext.SqlSugarClient, + allFileEntities); + + var level1 = categories.Where(x => string.IsNullOrWhiteSpace(x.ParentId)).ToList(); + var level2Map = categories + .Where(x => !string.IsNullOrWhiteSpace(x.ParentId)) + .GroupBy(x => x.ParentId!.Trim(), StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + + var result = new List(); + foreach (var l1 in level1) + { + var children = level2Map.TryGetValue(l1.Id, out var l2List) + ? l2List.OrderByDescending(x => x.OrderNum).ThenByDescending(x => x.CreationTime).ToList() + : new List(); + + var childNodes = new List(); + foreach (var l2 in children) + { + filesByCategory.TryGetValue(l2.Id, out var fileRows); + fileRows ??= new List(); + + var nameMatch = string.IsNullOrWhiteSpace(keyword) + || l2.CategoryName.Contains(keyword!, StringComparison.OrdinalIgnoreCase); + var fileMatch = fileRows.Count > 0; + if (!string.IsNullOrWhiteSpace(keyword) && !nameMatch && !fileMatch) + { + continue; + } + + if (scopeContext is not null && fileRows.Count == 0 && !nameMatch) + { + continue; + } + + childNodes.Add(MapCategoryNode(l2, fileRows, scopeDisplayMap)); + } + + var l1NameMatch = string.IsNullOrWhiteSpace(keyword) + || l1.CategoryName.Contains(keyword!, StringComparison.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(keyword) && !l1NameMatch && childNodes.Count == 0) + { + continue; + } + + if (scopeContext is not null && childNodes.Count == 0 && !l1NameMatch) + { + continue; + } + + result.Add(new TrainingCategoryTreeNodeDto + { + Id = l1.Id, + CategoryName = l1.CategoryName, + ParentId = null, + OrderNum = l1.OrderNum, + Children = childNodes, + Files = new List() + }); + } + + return result; + } + + /// + /// 新增培训分类(一级或二级) + /// + /// + /// 示例请求: + /// ```json + /// { + /// "categoryName": "食品安全", + /// "parentId": null, + /// "orderNum": 100 + /// } + /// ``` + /// + /// 分类信息 + /// 新建分类 + /// 创建成功 + /// 参数无效或父级不存在 + /// 服务器错误 + public async Task CreateCategoryAsync(TrainingCategoryCreateInputVo input) + { + var name = input.CategoryName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + throw new UserFriendlyException("分类名称不能为空"); + } + + var parentId = string.IsNullOrWhiteSpace(input.ParentId) ? null : input.ParentId.Trim(); + if (parentId is not null) + { + var parent = await _dbContext.SqlSugarClient.Queryable() + .FirstAsync(x => x.Id == parentId && !x.IsDeleted); + if (parent is null) + { + throw new UserFriendlyException("父级分类不存在"); + } + + if (!string.IsNullOrWhiteSpace(parent.ParentId)) + { + throw new UserFriendlyException("仅支持两级分类,不能在二级分类下再建子级"); + } + } + + var duplicated = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.CategoryName == name && x.ParentId == parentId); + if (duplicated) + { + throw new UserFriendlyException("同级分类名称已存在"); + } + + var now = DateTime.Now; + var currentUserId = CurrentUser?.Id?.ToString(); + var entity = new FlTrainingCategoryDbEntity + { + Id = YitIdHelper.NextId().ToString(), + CategoryName = name, + ParentId = parentId, + OrderNum = input.OrderNum, + IsDeleted = false, + CreationTime = now, + CreatorId = currentUserId, + LastModificationTime = now, + LastModifierId = currentUserId, + ConcurrencyStamp = YitIdHelper.NextId().ToString() + }; + + await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); + return MapCategoryOutput(entity); + } + + /// + /// 编辑培训分类 + /// + /// + /// 示例请求: + /// ```json + /// { + /// "categoryName": "食品安全(更新)", + /// "orderNum": 90 + /// } + /// ``` + /// + /// 分类Id + /// 分类信息 + /// 更新后的分类 + /// 更新成功 + /// 分类不存在或名称重复 + /// 服务器错误 + public async Task UpdateCategoryAsync(string id, TrainingCategoryUpdateInputVo input) + { + var entity = await GetCategoryOrThrowAsync(id); + var name = input.CategoryName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + throw new UserFriendlyException("分类名称不能为空"); + } + + var duplicated = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.Id != id && x.CategoryName == name && x.ParentId == entity.ParentId); + if (duplicated) + { + throw new UserFriendlyException("同级分类名称已存在"); + } + + entity.CategoryName = name; + entity.OrderNum = input.OrderNum; + entity.LastModificationTime = DateTime.Now; + entity.LastModifierId = CurrentUser?.Id?.ToString(); + + await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); + return MapCategoryOutput(entity); + } + + /// + /// 删除培训分类(软删) + /// + /// + /// 一级分类存在二级子分类时不可删除;二级分类存在文件时不可删除。 + /// + /// 分类Id + /// 删除成功 + /// 存在子分类或文件 + /// 服务器错误 + public async Task DeleteCategoryAsync(string id) + { + var entity = await GetCategoryOrThrowAsync(id); + + if (string.IsNullOrWhiteSpace(entity.ParentId)) + { + var hasChild = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.ParentId == id); + if (hasChild) + { + throw new UserFriendlyException("该一级分类下存在二级分类,无法删除"); + } + } + else + { + var hasFile = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.CategoryId == id); + if (hasFile) + { + throw new UserFriendlyException("该二级分类下存在培训文件,无法删除"); + } + } + + entity.IsDeleted = true; + entity.LastModificationTime = DateTime.Now; + entity.LastModifierId = CurrentUser?.Id?.ToString(); + await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); + } + + /// + /// 上传培训文件到二级分类(可同时提交 Company / Region / Location 适用范围) + /// + /// + /// multipart/form-data:file、categoryId、orderNum,及可选 scope 字段。 + /// 支持常见图片与 pdf/doc/docx/xlsx 等,单文件最大 20MB。 + /// + /// 示例 form 字段: + /// - file: 文件 + /// - categoryId: 二级分类 Id + /// - appliedPartnerType: ALL / SPECIFIED + /// - partnerIds: 可重复传值或含 ALL + /// - appliedRegionType: ALL / SPECIFIED + /// - regionIds / groupIds: 可含 ALL + /// - availabilityType 或 appliedLocationType: ALL / SPECIFIED + /// - locationIds: 可含 ALL + /// + /// 上传表单 + /// 文件信息 + /// 上传成功 + /// 文件无效或分类不是二级 + /// 服务器错误 + [HttpPost] + [Consumes("multipart/form-data")] + [Route("/api/app/training/file/upload")] + public async Task UploadFileAsync([FromForm] TrainingFileUploadInputVo input) + { + if (input.File is null || input.File.Length <= 0) + { + throw new UserFriendlyException("请选择要上传的文件"); + } + + if (input.File.Length > MaxFileSizeBytes) + { + throw new UserFriendlyException("文件大小不能超过20MB"); + } + + var categoryId = input.CategoryId?.Trim(); + if (string.IsNullOrWhiteSpace(categoryId)) + { + throw new UserFriendlyException("二级分类Id不能为空"); + } + + var category = await GetCategoryOrThrowAsync(categoryId); + if (string.IsNullOrWhiteSpace(category.ParentId)) + { + throw new UserFriendlyException("文件只能上传到二级分类"); + } + + var ext = Path.GetExtension(input.File.FileName ?? string.Empty); + if (string.IsNullOrWhiteSpace(ext) || !AllowedExtensions.Contains(ext)) + { + throw new UserFriendlyException("不支持的文件格式"); + } + + var saveRoot = ResolveTrainingRoot(); + Directory.CreateDirectory(saveRoot); + + var storedName = $"{DateTime.Now:yyyyMMddHHmmss}_{YitIdHelper.NextId()}{ext.ToLowerInvariant()}"; + var savePath = Path.Combine(saveRoot, storedName); + + await using (var stream = new FileStream(savePath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + await input.File.CopyToAsync(stream); + } + + var now = DateTime.Now; + var currentUserId = CurrentUser?.Id?.ToString(); + var hasScopeInput = HasScopeInput(input); + var scope = hasScopeInput + ? await TrainingFileScopeHelper.ResolveScopeForSaveAsync( + _dbContext.SqlSugarClient, + input.AppliedPartnerType, + input.PartnerIds, + input.CompanyIds, + input.AppliedRegionType, + input.RegionIds, + input.GroupIds, + ResolveLocationTypeInput(input), + input.LocationIds) + : null; + + var entity = new FlTrainingFileDbEntity + { + Id = YitIdHelper.NextId().ToString(), + CategoryId = categoryId, + FileName = Path.GetFileName(input.File.FileName ?? storedName), + FileUrl = BuildTrainingUrl(storedName), + FileType = ResolveFileType(ext), + FileSize = input.File.Length, + OrderNum = input.OrderNum, + AppliedPartnerType = scope?.AppliedPartnerType ?? AllScopeBindingHelper.ScopeAll, + AppliedRegionType = scope?.AppliedRegionType ?? AllScopeBindingHelper.ScopeAll, + AvailabilityType = scope?.AvailabilityType ?? AllScopeBindingHelper.ScopeAll, + IsDeleted = false, + CreationTime = now, + CreatorId = currentUserId, + LastModificationTime = now, + LastModifierId = currentUserId, + ConcurrencyStamp = YitIdHelper.NextId().ToString() + }; + + await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); + if (scope is not null) + { + await TrainingFileScopeHelper.SaveScopeAsync( + _dbContext.SqlSugarClient, + entity.Id, + scope, + currentUserId, + now); + } + + var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity); + return MapFileDto(entity, display); + } + + /// + /// 编辑培训文件元数据及适用范围 + /// + /// + /// 示例请求: + /// ```json + /// { + /// "fileName": "操作手册.pdf", + /// "orderNum": 100, + /// "appliedPartnerType": "SPECIFIED", + /// "partnerIds": ["p1"], + /// "appliedRegionType": "ALL", + /// "availabilityType": "SPECIFIED", + /// "locationIds": ["loc1"] + /// } + /// ``` + /// + /// 参数说明: + /// - fileName / orderNum: 文件元数据 + /// - appliedPartnerType / partnerIds / companyIds: Company 范围,Id 可含 ALL + /// - appliedRegionType / regionIds / groupIds: Region 范围,Id 可含 ALL + /// - availabilityType 或 appliedLocationType / locationIds: Location 范围,Id 可含 ALL + /// + /// 文件Id + /// 文件元数据 + /// 更新后的文件 + /// 更新成功 + /// 文件不存在 + /// 服务器错误 + public async Task UpdateFileAsync(string id, TrainingFileUpdateInputVo input) + { + var entity = await GetFileOrThrowAsync(id); + var fileName = input.FileName?.Trim(); + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new UserFriendlyException("文件名称不能为空"); + } + + entity.FileName = fileName; + entity.OrderNum = input.OrderNum; + entity.LastModificationTime = DateTime.Now; + entity.LastModifierId = CurrentUser?.Id?.ToString(); + + await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); + + if (HasScopeInput(input)) + { + entity = await ApplyFileScopeAsync(entity, input); + } + + var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity); + return MapFileDto(entity, display); + } + + /// + /// 删除培训文件(软删) + /// + /// 文件Id + /// 删除成功 + /// 文件不存在 + /// 服务器错误 + public async Task DeleteFileAsync(string id) + { + var entity = await GetFileOrThrowAsync(id); + await TrainingFileScopeHelper.DeleteScopeRowsAsync(_dbContext.SqlSugarClient, entity.Id); + + entity.IsDeleted = true; + entity.LastModificationTime = DateTime.Now; + entity.LastModifierId = CurrentUser?.Id?.ToString(); + await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); + } + + /// + /// 批量更新培训文件排序 + /// + /// + /// 示例请求: + /// ```json + /// { + /// "items": [ + /// { "id": "123", "orderNum": 100 }, + /// { "id": "456", "orderNum": 90 } + /// ] + /// } + /// ``` + /// + /// 排序项 + /// 排序成功 + /// 存在无效文件Id + /// 服务器错误 + public async Task SortFilesAsync(TrainingFileSortInputVo input) + { + if (input.Items is null || input.Items.Count == 0) + { + return; + } + + var ids = input.Items.Select(x => x.Id?.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast().ToList(); + if (ids.Count == 0) + { + return; + } + + var existing = await _dbContext.SqlSugarClient.Queryable() + .Where(x => !x.IsDeleted && ids.Contains(x.Id)) + .ToListAsync(); + var map = existing.ToDictionary(x => x.Id, StringComparer.Ordinal); + var now = DateTime.Now; + var userId = CurrentUser?.Id?.ToString(); + + foreach (var item in input.Items) + { + if (string.IsNullOrWhiteSpace(item.Id) || !map.TryGetValue(item.Id.Trim(), out var entity)) + { + continue; + } + + entity.OrderNum = item.OrderNum; + entity.LastModificationTime = now; + entity.LastModifierId = userId; + } + + if (existing.Count > 0) + { + await _dbContext.SqlSugarClient.Updateable(existing).ExecuteCommandAsync(); + } + } + + /// + /// 获取培训文件权限范围(兼容独立查询;主路径为 create/update 携带 scope) + /// + /// 文件Id + /// 权限范围 + /// 成功 + /// 文件不存在 + /// 服务器错误 + [HttpGet] + [Route("/api/app/training/file-scope/{id}")] + public async Task GetFileScopeAsync(string id) + { + var entity = await GetFileOrThrowAsync(id); + var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity); + return MapScopeOutput(display); + } + + /// + /// 设置培训文件权限范围(兼容独立编辑;主路径为 create/update 携带 scope) + /// + /// + /// 示例请求: + /// ```json + /// { + /// "appliedPartnerType": "SPECIFIED", + /// "partnerIds": ["p1"], + /// "appliedRegionType": "ALL", + /// "availabilityType": "SPECIFIED", + /// "locationIds": ["loc1"] + /// } + /// ``` + /// + /// 文件Id + /// 权限范围 + /// 更新后的权限范围 + /// 设置成功 + /// 参数无效 + /// 服务器错误 + [HttpPut] + [Route("/api/app/training/file-scope/{id}")] + public async Task SetFileScopeAsync(string id, TrainingFileScopeInputVo input) + { + var entity = await GetFileOrThrowAsync(id); + entity = await ApplyFileScopeAsync(entity, input); + var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity); + return MapScopeOutput(display); + } + + private async Task ApplyFileScopeAsync( + FlTrainingFileDbEntity entity, + ITrainingFileScopeInput input) + { + var scope = await TrainingFileScopeHelper.ResolveScopeForSaveAsync( + _dbContext.SqlSugarClient, + input.AppliedPartnerType, + input.PartnerIds, + input.CompanyIds, + input.AppliedRegionType, + input.RegionIds, + input.GroupIds, + ResolveLocationTypeInput(input), + input.LocationIds); + + entity.AppliedPartnerType = scope.AppliedPartnerType; + entity.AppliedRegionType = scope.AppliedRegionType; + entity.AvailabilityType = scope.AvailabilityType; + entity.LastModificationTime = DateTime.Now; + entity.LastModifierId = CurrentUser?.Id?.ToString(); + + await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); + await TrainingFileScopeHelper.SaveScopeAsync( + _dbContext.SqlSugarClient, + entity.Id, + scope, + entity.LastModifierId, + entity.LastModificationTime ?? DateTime.Now); + + return entity; + } + + private static bool HasScopeInput(ITrainingFileScopeInput input) => + !string.IsNullOrWhiteSpace(input.AppliedPartnerType) + || input.PartnerIds is not null + || input.CompanyIds is not null + || !string.IsNullOrWhiteSpace(input.AppliedRegionType) + || input.RegionIds is not null + || input.GroupIds is not null + || !string.IsNullOrWhiteSpace(input.AvailabilityType) + || !string.IsNullOrWhiteSpace(input.AppliedLocationType) + || input.LocationIds is not null; + + private static string? ResolveLocationTypeInput(ITrainingFileScopeInput input) => + string.IsNullOrWhiteSpace(input.AvailabilityType) + ? input.AppliedLocationType + : input.AvailabilityType; + + private async Task GetFileOrThrowAsync(string id) + { + var entity = await _dbContext.SqlSugarClient.Queryable() + .FirstAsync(x => x.Id == id && !x.IsDeleted); + if (entity is null) + { + throw new UserFriendlyException("培训文件不存在"); + } + + return entity; + } + + private async Task GetCategoryOrThrowAsync(string id) + { + var entity = await _dbContext.SqlSugarClient.Queryable() + .FirstAsync(x => x.Id == id && !x.IsDeleted); + if (entity is null) + { + throw new UserFriendlyException("分类不存在"); + } + + return entity; + } + + private static TrainingCategoryGetOutputDto MapCategoryOutput(FlTrainingCategoryDbEntity entity) => + new() + { + Id = entity.Id, + CategoryName = entity.CategoryName, + ParentId = entity.ParentId, + OrderNum = entity.OrderNum, + CreationTime = entity.CreationTime, + LastModificationTime = entity.LastModificationTime + }; + + private static TrainingCategoryTreeNodeDto MapCategoryNode( + FlTrainingCategoryDbEntity entity, + List files, + IReadOnlyDictionary scopeDisplayMap) => + new() + { + Id = entity.Id, + CategoryName = entity.CategoryName, + ParentId = entity.ParentId, + OrderNum = entity.OrderNum, + Children = new List(), + Files = files.Select(file => + { + scopeDisplayMap.TryGetValue(file.Id, out var display); + return MapFileDto(file, display); + }).ToList() + }; + + private static TrainingFileDto MapFileDto( + FlTrainingFileDbEntity entity, + TrainingFileScopeHelper.TrainingFileScopeDisplay? display = null) => + new() + { + Id = entity.Id, + CategoryId = entity.CategoryId, + FileName = entity.FileName, + FileUrl = entity.FileUrl, + FileType = entity.FileType, + FileSize = entity.FileSize, + OrderNum = entity.OrderNum, + AppliedPartnerType = display?.AppliedPartnerType ?? entity.AppliedPartnerType, + Company = display?.Company ?? string.Empty, + PartnerIds = display?.PartnerIds ?? new List(), + CompanyIds = display?.PartnerIds ?? new List(), + AppliedRegionType = display?.AppliedRegionType ?? entity.AppliedRegionType, + Region = display?.Region ?? string.Empty, + RegionIds = display?.RegionIds ?? new List(), + GroupIds = display?.RegionIds ?? new List(), + AvailabilityType = display?.AvailabilityType ?? entity.AvailabilityType, + Location = display?.Location ?? string.Empty, + LocationIds = display?.LocationIds ?? new List(), + CreationTime = entity.CreationTime, + LastModificationTime = entity.LastModificationTime + }; + + private static TrainingFileScopeOutputDto MapScopeOutput(TrainingFileScopeHelper.TrainingFileScopeDisplay display) => + new() + { + AppliedPartnerType = display.AppliedPartnerType, + Company = display.Company, + PartnerIds = display.PartnerIds, + CompanyIds = display.PartnerIds, + AppliedRegionType = display.AppliedRegionType, + Region = display.Region, + RegionIds = display.RegionIds, + GroupIds = display.RegionIds, + AvailabilityType = display.AvailabilityType, + Location = display.Location, + LocationIds = display.LocationIds + }; + + private string ResolveTrainingRoot() + { + var linuxRoot = "/www/wwwroot/FoodLabelingManagementSAAS/training"; + var webRoot = Path.Combine(_hostEnvironment.ContentRootPath, "wwwroot", "FoodLabelingManagementSAAS", "training"); + return Directory.Exists(linuxRoot) ? linuxRoot : webRoot; + } + + private static string BuildTrainingUrl(string fileName) => $"/training/{fileName}"; + + private static string ResolveFileType(string ext) + { + if (ImageExtensions.Contains(ext)) + { + return TrainingFileType.Image.ToString().ToLowerInvariant(); + } + + if (DocExtensions.Contains(ext)) + { + return TrainingFileType.Doc.ToString().ToLowerInvariant(); + } + + return TrainingFileType.Other.ToString().ToLowerInvariant(); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs index 0522fcb..7bd1c08 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IdentityModel.Tokens.Jwt; @@ -98,12 +98,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService [AllowAnonymous] public virtual async Task LoginAsync(UsAppLoginInputVo input) { - if (_dbConnOptions.EnabledSaasMultiTenancy && !CurrentTenant.Id.HasValue) - { - throw new UserFriendlyException( - "多租户模式下请使用泰额 App 登录接口 /api/app/th-app-auth/login(须传 tenantId),勿使用 us-app-auth。"); - } - + // 泰额 SaaS:由 ThUsAppAuthAppService 覆盖本方法,按邮箱反查租户后登录;此处仅单库模式。 if (string.IsNullOrWhiteSpace(input.Password) || string.IsNullOrWhiteSpace(input.Email)) { throw new UserFriendlyException("请输入合理数据!"); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs index 2227bcf..19170ec 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs @@ -541,6 +541,10 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ { var existedBatchId = existed.First().BatchId; var existedTaskIds = existed.Select(x => x.Id).ToList(); + await LabelAlertTimerWriteHelper.TryCreateFromPrintBatchAsync( + _dbContext.SqlSugarClient, + existedBatchId ?? string.Empty, + CurrentUser?.Id?.ToString()); return new UsAppLabelPrintOutputDto { TaskId = existedTaskIds.FirstOrDefault() ?? string.Empty, @@ -689,6 +693,11 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ } } + await LabelAlertTimerWriteHelper.TryCreateFromPrintBatchAsync( + _dbContext.SqlSugarClient, + batchId, + currentUserId); + return new UsAppLabelPrintOutputDto { TaskId = taskIds.FirstOrDefault() ?? string.Empty, @@ -736,6 +745,10 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ { var existedBatchId = existed.First().BatchId; var existedTaskIds = existed.Select(x => x.Id).ToList(); + await LabelAlertTimerWriteHelper.TryCreateFromPrintBatchAsync( + _dbContext.SqlSugarClient, + existedBatchId ?? string.Empty, + CurrentUser?.Id?.ToString()); return new UsAppLabelPrintOutputDto { TaskId = existedTaskIds.FirstOrDefault() ?? string.Empty, @@ -846,6 +859,11 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ } } + await LabelAlertTimerWriteHelper.TryCreateFromPrintBatchAsync( + _dbContext.SqlSugarClient, + batchId, + currentUserId); + return new UsAppLabelPrintOutputDto { TaskId = taskIds.FirstOrDefault() ?? string.Empty, diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppTrainingAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppTrainingAppService.cs new file mode 100644 index 0000000..3859eb2 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppTrainingAppService.cs @@ -0,0 +1,70 @@ +using FoodLabeling.Application.Contracts.Dtos.Training; +using FoodLabeling.Application.Contracts.IServices; +using FoodLabeling.Application.Helpers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Volo.Abp; +using Volo.Abp.Application.Services; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Application.Services; + +/// +/// App 培训 / 资料中心:按门店返回可见的分类树与文件 +/// +public class UsAppTrainingAppService : ApplicationService, IUsAppTrainingAppService +{ + private readonly ISqlSugarDbContext _dbContext; + private readonly ITrainingAppService _trainingAppService; + + public UsAppTrainingAppService(ISqlSugarDbContext dbContext, ITrainingAppService trainingAppService) + { + _dbContext = dbContext; + _trainingAppService = trainingAppService; + } + + /// + /// 按门店获取可见的培训分类树与文件 + /// + /// + /// 按 locationId 过滤文件权限:Company / Region / Location 三维度 ALL 或 SPECIFIED 命中该门店。 + /// + /// 示例请求: + /// ```json + /// { + /// "locationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + /// "keyword": "安全" + /// } + /// ``` + /// + /// 参数说明: + /// - locationId: 当前门店 Id(必填) + /// - keyword: 可选,匹配分类名或文件名 + /// + /// 查询条件 + /// 分类树(含可见文件) + /// 成功返回分类树 + /// 门店Id无效或无权限 + /// 服务器错误 + [Authorize] + public async Task> GetTreeAsync([FromQuery] UsAppTrainingTreeInputVo input) + { + if (string.IsNullOrWhiteSpace(input.LocationId)) + { + throw new UserFriendlyException("门店Id不能为空"); + } + + var locationId = input.LocationId.Trim(); + await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync( + CurrentUser, + _dbContext.SqlSugarClient, + locationId); + + return await _trainingAppService.GetCategoryTreeAsync(new TrainingCategoryTreeInputVo + { + Keyword = input.Keyword, + LocationId = locationId, + IncludeFiles = true + }); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Domain.Shared/Enums/TrainingFileType.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Domain.Shared/Enums/TrainingFileType.cs new file mode 100644 index 0000000..95daa74 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Domain.Shared/Enums/TrainingFileType.cs @@ -0,0 +1,16 @@ +namespace FoodLabeling.Domain.Shared.Enums; + +/// +/// 培训文件类型 +/// +public enum TrainingFileType +{ + /// 图片 + Image = 0, + + /// 文档(pdf/doc/xlsx 等) + Doc = 1, + + /// 其它 + Other = 2 +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Domain.Shared/Helpers/YitIdHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Domain.Shared/Helpers/YitIdHelper.cs new file mode 100644 index 0000000..669c777 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Domain.Shared/Helpers/YitIdHelper.cs @@ -0,0 +1,63 @@ +namespace FoodLabeling.Domain.Shared.Helpers; + +/// +/// 雪花 Id 生成器(项目规范:实体 string Id 使用 NextId().ToString()) +/// +public static class YitIdHelper +{ + private const long Epoch = 1_609_459_200_000L; + private const int WorkerIdBits = 5; + private const int SequenceBits = 12; + private const long MaxSequence = (1L << SequenceBits) - 1; + private const int WorkerIdShift = SequenceBits; + private const int TimestampShift = SequenceBits + WorkerIdBits; + private const long WorkerId = 1; + + private static long _lastTimestamp = -1L; + private static long _sequence; + private static readonly object SyncRoot = new(); + + /// + /// 生成下一个雪花 Id + /// + public static long NextId() + { + lock (SyncRoot) + { + var timestamp = CurrentTimestamp(); + if (timestamp < _lastTimestamp) + { + timestamp = WaitNextMillis(_lastTimestamp); + } + + if (_lastTimestamp == timestamp) + { + _sequence = (_sequence + 1) & MaxSequence; + if (_sequence == 0) + { + timestamp = WaitNextMillis(_lastTimestamp); + } + } + else + { + _sequence = 0; + } + + _lastTimestamp = timestamp; + return ((timestamp - Epoch) << TimestampShift) | (WorkerId << WorkerIdShift) | _sequence; + } + } + + private static long CurrentTimestamp() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private static long WaitNextMillis(long lastTimestamp) + { + var timestamp = CurrentTimestamp(); + while (timestamp <= lastTimestamp) + { + timestamp = CurrentTimestamp(); + } + + return timestamp; + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_entity_applied_region_type.sql b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_entity_applied_region_type.sql new file mode 100644 index 0000000..0ed4e88 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_entity_applied_region_type.sql @@ -0,0 +1,46 @@ +-- 标签/产品分类等实体:适用 Region 维度 ALL/SPECIFIED(支持 Region=ALL + 指定门店) +-- 执行前请备份;可重复执行 + +SET @db := DATABASE(); + +-- fl_label_category +SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fl_label_category' AND COLUMN_NAME='AppliedRegionType'); +SET @ddl := IF(@c=0, + 'ALTER TABLE `fl_label_category` ADD COLUMN `AppliedRegionType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Region:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- fl_product_category +SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fl_product_category' AND COLUMN_NAME='AppliedRegionType'); +SET @ddl := IF(@c=0, + 'ALTER TABLE `fl_product_category` ADD COLUMN `AppliedRegionType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Region:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- fl_label_type +SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fl_label_type' AND COLUMN_NAME='AppliedRegionType'); +SET @ddl := IF(@c=0, + 'ALTER TABLE `fl_label_type` ADD COLUMN `AppliedRegionType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Region:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- fl_label_multiple_option +SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fl_label_multiple_option' AND COLUMN_NAME='AppliedRegionType'); +SET @ddl := IF(@c=0, + 'ALTER TABLE `fl_label_multiple_option` ADD COLUMN `AppliedRegionType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Region:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- fl_product +SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fl_product' AND COLUMN_NAME='AppliedRegionType'); +SET @ddl := IF(@c=0, + 'ALTER TABLE `fl_product` ADD COLUMN `AppliedRegionType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Region:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- 已有 SPECIFIED 门店范围的数据:Region 视为 SPECIFIED(由门店反推) +UPDATE `fl_label_category` SET `AppliedRegionType`='SPECIFIED' WHERE `AvailabilityType`='SPECIFIED'; +UPDATE `fl_product_category` SET `AppliedRegionType`='SPECIFIED' WHERE `AvailabilityType`='SPECIFIED'; +UPDATE `fl_label_type` SET `AppliedRegionType`='SPECIFIED' WHERE `AvailabilityType`='SPECIFIED'; +UPDATE `fl_label_multiple_option` SET `AppliedRegionType`='SPECIFIED' WHERE `AvailabilityType`='SPECIFIED'; +UPDATE `fl_product` SET `AppliedRegionType`='SPECIFIED' WHERE `AvailabilityType`='SPECIFIED'; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_group_create.sql b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_group_create.sql index fa5c45a..51372f3 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_group_create.sql +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_group_create.sql @@ -18,4 +18,4 @@ CREATE TABLE IF NOT EXISTS `fl_group` ( KEY `IX_fl_group_GroupName` (`GroupName`(128)), KEY `IX_fl_group_CreationTime` (`CreationTime`), CONSTRAINT `FK_fl_group_partner` FOREIGN KEY (`PartnerId`) REFERENCES `fl_partner` (`Id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织(Group)'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='组织(Group)'; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_alert_timer.sql b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_alert_timer.sql new file mode 100644 index 0000000..60dbfea --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_alert_timer.sql @@ -0,0 +1,31 @@ +-- 标签告警计时器:按打印批次(BatchId)一条,过期时刻与 Print Log Expiration 同源解析 +-- 执行前请备份;可重复执行 + +SET @db := DATABASE(); + +CREATE TABLE IF NOT EXISTS `fl_label_alert_timer` ( + `Id` varchar(36) NOT NULL COMMENT '主键', + `BatchId` varchar(36) NOT NULL COMMENT '打印批次Id(fl_label_print_task.BatchId,唯一)', + `PrintTaskId` varchar(36) NOT NULL COMMENT '代表任务Id(CopyIndex 最小)', + `LabelId` varchar(36) NOT NULL COMMENT '标签Id', + `LabelCode` varchar(100) DEFAULT NULL COMMENT '标签编码', + `LabelName` varchar(200) NOT NULL COMMENT '标签名称', + `ProductId` varchar(36) DEFAULT NULL COMMENT '产品Id', + `ProductName` varchar(500) DEFAULT NULL COMMENT '产品名称', + `LocationId` varchar(36) NOT NULL COMMENT '门店Id', + `PrintedAt` datetime NOT NULL COMMENT '打印时刻(PrintedAt ?? BaseTime ?? CreationTime)', + `BaseTime` datetime DEFAULT NULL COMMENT '基准时间', + `ExpiresAt` datetime NOT NULL COMMENT '过期时刻', + `DurationSeconds` int NOT NULL DEFAULT 0 COMMENT '总时长(秒)', + `Title` varchar(500) NOT NULL COMMENT '标题', + `Subtitle` varchar(500) NOT NULL COMMENT '副标题', + `IsDeleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '软删', + `DeletionTime` datetime DEFAULT NULL COMMENT '删除时间', + `CreatedBy` varchar(36) DEFAULT NULL COMMENT '创建者', + `CreationTime` datetime NOT NULL COMMENT '创建时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_fl_label_alert_timer_batch` (`BatchId`), + KEY `idx_fl_label_alert_timer_location` (`LocationId`), + KEY `idx_fl_label_alert_timer_expires` (`ExpiresAt`), + KEY `idx_fl_label_alert_timer_printed` (`PrintedAt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='标签告警计时器'; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_partner_id.sql b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_partner_id.sql new file mode 100644 index 0000000..8c062c8 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_partner_id.sql @@ -0,0 +1,15 @@ +-- fl_label 适用 Company 单选(PartnerId) +-- 执行前请备份;可重复执行 + +SET @db := DATABASE(); + +SET @c := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'fl_label' AND COLUMN_NAME = 'PartnerId' +); +SET @ddl := IF( + @c = 0, + 'ALTER TABLE `fl_label` ADD COLUMN `PartnerId` varchar(36) NULL DEFAULT NULL COMMENT ''适用Company(fl_partner.Id,单选)'' AFTER `LocationId`', + 'SELECT 1' +); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql index 0efde28..c280d85 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql @@ -22,4 +22,4 @@ CREATE TABLE IF NOT EXISTS `fl_partner` ( KEY `IX_fl_partner_State` (`State`), KEY `IX_fl_partner_CreationTime` (`CreationTime`), KEY `IX_fl_partner_PartnerName` (`PartnerName`(128)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合作伙伴'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='合作伙伴'; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_product_category_partner_scope.sql b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_product_category_partner_scope.sql new file mode 100644 index 0000000..3edac07 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_product_category_partner_scope.sql @@ -0,0 +1,28 @@ +-- 产品分类适用 Company 多选(ALL/SPECIFIED) +-- 执行前请备份;可重复执行 + +SET @db := DATABASE(); + +SET @col_pc_partner := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'fl_product_category' AND COLUMN_NAME = 'AppliedPartnerType' +); +SET @ddl_pc_partner := IF( + @col_pc_partner = 0, + 'ALTER TABLE `fl_product_category` ADD COLUMN `AppliedPartnerType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Company:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1' +); +PREPARE stmt_pc_partner FROM @ddl_pc_partner; +EXECUTE stmt_pc_partner; +DEALLOCATE PREPARE stmt_pc_partner; + +CREATE TABLE IF NOT EXISTS `fl_product_category_partner` ( + `Id` varchar(36) NOT NULL COMMENT '主键', + `CategoryId` varchar(36) NOT NULL COMMENT 'fl_product_category.Id', + `PartnerId` varchar(36) NOT NULL COMMENT 'fl_partner.Id', + `CreationTime` datetime NOT NULL COMMENT '创建时间', + `CreatorId` varchar(36) DEFAULT NULL COMMENT '创建者', + PRIMARY KEY (`Id`), + KEY `idx_fl_pcatp_category` (`CategoryId`), + KEY `idx_fl_pcatp_partner` (`PartnerId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='产品分类适用Company'; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_team_member_scope.sql b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_team_member_scope.sql new file mode 100644 index 0000000..951f2db --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_team_member_scope.sql @@ -0,0 +1,13 @@ +-- Team Member:Region / Location 维度 ALL 标记(支持 Region=ALL + 指定门店,及反向) +-- 执行前请备份;可重复执行 + +SET @db := DATABASE(); + +CREATE TABLE IF NOT EXISTS `fl_team_member_scope` ( + `UserId` varchar(36) NOT NULL COMMENT '成员 User.Id', + `AppliedRegionType` varchar(20) NOT NULL DEFAULT 'SPECIFIED' COMMENT '适用Region:ALL/SPECIFIED', + `AppliedLocationType` varchar(20) NOT NULL DEFAULT 'SPECIFIED' COMMENT '适用Location:ALL/SPECIFIED', + `CreationTime` datetime NULL, + `LastModificationTime` datetime NULL, + PRIMARY KEY (`UserId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Team Member 适用范围维度标记'; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_training.sql b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_training.sql new file mode 100644 index 0000000..e16ce60 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_training.sql @@ -0,0 +1,75 @@ +-- 培训 / 资料中心:分类、文件及适用范围(Company / Region / Location) +-- 执行前请备份;可重复执行 + +SET @db := DATABASE(); + +CREATE TABLE IF NOT EXISTS `fl_training_category` ( + `Id` varchar(36) NOT NULL COMMENT '主键', + `CategoryName` varchar(200) NOT NULL COMMENT '分类名称', + `ParentId` varchar(36) DEFAULT NULL COMMENT '父级分类Id,空=一级分类', + `OrderNum` int NOT NULL DEFAULT 0 COMMENT '排序', + `IsDeleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '软删', + `CreationTime` datetime NOT NULL COMMENT '创建时间', + `CreatorId` varchar(36) DEFAULT NULL COMMENT '创建者', + `LastModificationTime` datetime DEFAULT NULL COMMENT '最后修改时间', + `LastModifierId` varchar(36) DEFAULT NULL COMMENT '最后修改者', + `ConcurrencyStamp` varchar(64) NOT NULL DEFAULT '' COMMENT '并发戳', + PRIMARY KEY (`Id`), + KEY `idx_fl_training_cat_parent` (`ParentId`), + KEY `idx_fl_training_cat_order` (`OrderNum`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='培训分类(一级/二级)'; + +CREATE TABLE IF NOT EXISTS `fl_training_file` ( + `Id` varchar(36) NOT NULL COMMENT '主键', + `CategoryId` varchar(36) NOT NULL COMMENT '二级分类Id', + `FileName` varchar(500) NOT NULL COMMENT '原始文件名', + `FileUrl` varchar(1000) NOT NULL COMMENT '访问路径', + `FileType` varchar(20) NOT NULL DEFAULT 'other' COMMENT '文件类型:image/doc/other', + `FileSize` bigint NOT NULL DEFAULT 0 COMMENT '字节大小', + `OrderNum` int NOT NULL DEFAULT 0 COMMENT '排序', + `AppliedPartnerType` varchar(20) NOT NULL DEFAULT 'ALL' COMMENT '适用Company:ALL/SPECIFIED', + `AppliedRegionType` varchar(20) NOT NULL DEFAULT 'ALL' COMMENT '适用Region:ALL/SPECIFIED', + `AvailabilityType` varchar(20) NOT NULL DEFAULT 'ALL' COMMENT '适用Location:ALL/SPECIFIED', + `IsDeleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '软删', + `CreationTime` datetime NOT NULL COMMENT '创建时间', + `CreatorId` varchar(36) DEFAULT NULL COMMENT '创建者', + `LastModificationTime` datetime DEFAULT NULL COMMENT '最后修改时间', + `LastModifierId` varchar(36) DEFAULT NULL COMMENT '最后修改者', + `ConcurrencyStamp` varchar(64) NOT NULL DEFAULT '' COMMENT '并发戳', + PRIMARY KEY (`Id`), + KEY `idx_fl_training_file_category` (`CategoryId`), + KEY `idx_fl_training_file_order` (`OrderNum`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='培训文件'; + +CREATE TABLE IF NOT EXISTS `fl_training_file_partner` ( + `Id` varchar(36) NOT NULL COMMENT '主键', + `TrainingFileId` varchar(36) NOT NULL COMMENT 'fl_training_file.Id', + `PartnerId` varchar(36) NOT NULL COMMENT 'fl_partner.Id', + `CreationTime` datetime NOT NULL COMMENT '创建时间', + `CreatorId` varchar(36) DEFAULT NULL COMMENT '创建者', + PRIMARY KEY (`Id`), + KEY `idx_fl_tfp_file` (`TrainingFileId`), + KEY `idx_fl_tfp_partner` (`PartnerId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='培训文件适用Company'; + +CREATE TABLE IF NOT EXISTS `fl_training_file_region` ( + `Id` varchar(36) NOT NULL COMMENT '主键', + `TrainingFileId` varchar(36) NOT NULL COMMENT 'fl_training_file.Id', + `GroupId` varchar(36) NOT NULL COMMENT 'fl_group.Id', + `CreationTime` datetime NOT NULL COMMENT '创建时间', + `CreatorId` varchar(36) DEFAULT NULL COMMENT '创建者', + PRIMARY KEY (`Id`), + KEY `idx_fl_tfr_file` (`TrainingFileId`), + KEY `idx_fl_tfr_group` (`GroupId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='培训文件适用Region'; + +CREATE TABLE IF NOT EXISTS `fl_training_file_location` ( + `Id` varchar(36) NOT NULL COMMENT '主键', + `TrainingFileId` varchar(36) NOT NULL COMMENT 'fl_training_file.Id', + `LocationId` varchar(36) NOT NULL COMMENT 'location.Id', + `CreationTime` datetime NOT NULL COMMENT '创建时间', + `CreatorId` varchar(36) DEFAULT NULL COMMENT '创建者', + PRIMARY KEY (`Id`), + KEY `idx_fl_tfl_file` (`TrainingFileId`), + KEY `idx_fl_tfl_location` (`LocationId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='培训文件适用Location'; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_userlocation.sql b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_userlocation.sql new file mode 100644 index 0000000..82017ca --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_userlocation.sql @@ -0,0 +1,20 @@ +-- 成员-门店关联表(UserLocationDbEntity 标记 IgnoreCodeFirst,需脚本建表) +-- 可重复执行 + +SET @db := DATABASE(); + +CREATE TABLE IF NOT EXISTS `userlocation` ( + `Id` varchar(36) NOT NULL COMMENT '主键(GUID)', + `IsDeleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除:0=未删除,1=已删除', + `CreationTime` datetime NOT NULL COMMENT '创建时间', + `CreatorId` varchar(36) DEFAULT NULL COMMENT '创建人Id', + `LastModifierId` varchar(36) DEFAULT NULL COMMENT '最后修改人Id', + `LastModificationTime` datetime DEFAULT NULL COMMENT '最后修改时间', + `UserId` varchar(36) NOT NULL COMMENT '成员Id(User.Id)', + `LocationId` varchar(36) NOT NULL COMMENT '门店Id(location.Id)', + `ConcurrencyStamp` varchar(255) NOT NULL DEFAULT '' COMMENT '并发戳', + PRIMARY KEY (`Id`), + UNIQUE KEY `UK_userlocation_user_location` (`UserId`,`LocationId`), + KEY `IX_userlocation_user` (`UserId`), + KEY `IX_userlocation_location` (`LocationId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='成员-门店关联表'; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/seed_host_platform_menus.sql b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/seed_host_platform_menus.sql new file mode 100644 index 0000000..8645253 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/seed_host_platform_menus.sql @@ -0,0 +1,103 @@ +-- 泰额版平台级菜单:清空 antis-foodlabeling-host.menu 后按前端路由表重录 +-- 使用稳定 Id(f001*),保证 rolemenu 等引用不失效 +-- MenuSource=0(Ruoyi),平台目录 MenuType=0,页面 MenuType=1 + +SET NAMES utf8mb4; +START TRANSACTION; + +DELETE FROM menu; + +INSERT INTO menu ( + Id, IsDeleted, CreationTime, CreatorId, LastModifierId, LastModificationTime, + OrderNum, State, MenuName, RouterName, MenuType, PermissionCode, ParentId, + MenuIcon, Router, IsLink, IsCache, IsShow, Remark, Component, MenuSource, Query, ConcurrencyStamp +) VALUES +-- 首页概览 +('f0010001-0001-4000-8000-000000000001', 0, NOW(), NULL, NULL, NULL, + -1, 1, '首页概览', 'FoodLabelingDashboard', 1, 'menu.dashboard.analytics', '0', + 'lucide:layout-dashboard', '/analytics', 0, 0, 1, NULL, '/food-labeling/dashboard/index', 0, NULL, ''), + +-- 平台管理 +('f0010002-0001-4000-8000-000000000002', 0, NOW(), NULL, NULL, NULL, + 5, 1, '平台管理', 'FoodLabelingPlatform', 0, 'menu.platform', '0', + 'lucide:cloud-cog', '/platform', 0, 0, 1, NULL, NULL, 0, NULL, ''), +('f0010003-0001-4000-8000-000000000003', 0, NOW(), NULL, NULL, NULL, + 6, 1, 'SAAS 公司', 'FoodLabelingPlatformTenants', 1, 'menu.platform.tenants', 'f0010002-0001-4000-8000-000000000002', + 'lucide:building', '/platform/tenants', 0, 0, 1, NULL, '/food-labeling/platform/tenants/index', 0, NULL, ''), + +-- 标签管理 +('f0010010-0001-4000-8000-000000000010', 0, NOW(), NULL, NULL, NULL, + 10, 1, '标签管理', 'FoodLabelingLabeling', 0, 'menu.labeling', '0', + 'lucide:tags', '/labeling', 0, 0, 1, NULL, NULL, 0, NULL, ''), +('f0010011-0001-4000-8000-000000000011', 0, NOW(), NULL, NULL, NULL, + 11, 1, '标签', 'FoodLabelingLabels', 1, 'menu.labeling.labels', 'f0010010-0001-4000-8000-000000000010', + 'lucide:tag', '/labels', 0, 0, 1, NULL, '/food-labeling/labeling/labels/index', 0, NULL, ''), +('f0010012-0001-4000-8000-000000000012', 0, NOW(), NULL, NULL, NULL, + 12, 1, '标签分类', 'FoodLabelingLabelCategories', 1, 'menu.labeling.categories', 'f0010010-0001-4000-8000-000000000010', + 'lucide:folder-tree', '/label-categories', 0, 0, 1, NULL, '/food-labeling/labeling/label-categories/index', 0, NULL, ''), +('f0010013-0001-4000-8000-000000000013', 0, NOW(), NULL, NULL, NULL, + 13, 1, '标签类型', 'FoodLabelingLabelTypes', 1, 'menu.labeling.types', 'f0010010-0001-4000-8000-000000000010', + 'lucide:layers', '/label-types', 0, 0, 1, NULL, '/food-labeling/labeling/label-types/index', 0, NULL, ''), +('f0010014-0001-4000-8000-000000000014', 0, NOW(), NULL, NULL, NULL, + 14, 1, '标签模板', 'FoodLabelingLabelTemplates', 1, 'menu.labeling.templates', 'f0010010-0001-4000-8000-000000000010', + 'lucide:layout-template', '/label-templates', 0, 0, 1, NULL, '/food-labeling/labeling/label-templates/index', 0, NULL, ''), +('f0010015-0001-4000-8000-000000000015', 0, NOW(), NULL, NULL, NULL, + 15, 1, '多选选项集', 'FoodLabelingMultipleOptions', 1, 'menu.labeling.multiple-options', 'f0010010-0001-4000-8000-000000000010', + 'lucide:list-checks', '/multiple-options', 0, 0, 1, NULL, '/food-labeling/labeling/multiple-options/index', 0, NULL, ''), + +-- 业务模块 +('f0010020-0001-4000-8000-000000000020', 0, NOW(), NULL, NULL, NULL, + 15, 1, '业务模块', 'FoodLabelingModules', 0, 'menu.modules', '0', + 'lucide:boxes', '/modules', 0, 0, 1, NULL, NULL, 0, NULL, ''), +('f0010021-0001-4000-8000-000000000021', 0, NOW(), NULL, NULL, NULL, + 16, 1, '培训', 'FoodLabelingTraining', 1, 'menu.modules.training', 'f0010020-0001-4000-8000-000000000020', + 'lucide:graduation-cap', '/training', 0, 0, 1, NULL, '/food-labeling/modules/training/index', 0, NULL, ''), +('f0010022-0001-4000-8000-000000000022', 0, NOW(), NULL, NULL, NULL, + 17, 1, '告警', 'FoodLabelingAlerts', 1, 'menu.modules.alerts', 'f0010020-0001-4000-8000-000000000020', + 'lucide:bell', '/alerts', 0, 0, 1, NULL, '/food-labeling/modules/alerts/index', 0, NULL, ''), +('f0010023-0001-4000-8000-000000000023', 0, NOW(), NULL, NULL, NULL, + 18, 1, '任务', 'FoodLabelingTasks', 1, 'menu.modules.tasks', 'f0010020-0001-4000-8000-000000000020', + 'lucide:list-todo', '/tasks', 0, 0, 1, NULL, '/food-labeling/modules/tasks/index', 0, NULL, ''), +('f0010024-0001-4000-8000-000000000024', 0, NOW(), NULL, NULL, NULL, + 19, 1, '传感器', 'FoodLabelingSensors', 1, 'menu.modules.sensors', 'f0010020-0001-4000-8000-000000000020', + 'lucide:activity', '/sensors', 0, 0, 1, NULL, '/food-labeling/modules/sensors/index', 0, NULL, ''), +('f0010025-0001-4000-8000-000000000025', 0, NOW(), NULL, NULL, NULL, + 20, 1, '食物浪费', 'FoodLabelingFoodWaste', 1, 'menu.modules.food-waste', 'f0010020-0001-4000-8000-000000000020', + 'lucide:apple', '/food-waste', 0, 0, 1, NULL, '/food-labeling/modules/food-waste/index', 0, NULL, ''), +('f0010026-0001-4000-8000-000000000026', 0, NOW(), NULL, NULL, NULL, + 21, 1, '电子标签', 'FoodLabelingELabelModule', 1, 'menu.modules.e-label', 'f0010020-0001-4000-8000-000000000020', + 'lucide:file-digit', '/e-label-module', 0, 0, 1, NULL, '/food-labeling/modules/e-label/index', 0, NULL, ''), + +-- 管理 +('f0010030-0001-4000-8000-000000000030', 0, NOW(), NULL, NULL, NULL, + 20, 1, '管理', 'FoodLabelingManagement', 0, 'menu.management', '0', + 'lucide:building-2', '/management', 0, 0, 1, NULL, NULL, 0, NULL, ''), +('f0010031-0001-4000-8000-000000000031', 0, NOW(), NULL, NULL, NULL, + 21, 1, '账户管理', 'FoodLabelingAccountManagement', 1, 'menu.management.account', 'f0010030-0001-4000-8000-000000000030', + 'lucide:users', '/account-management', 0, 0, 1, NULL, '/food-labeling/management/account-management/index', 0, NULL, ''), +('f0010032-0001-4000-8000-000000000032', 0, NOW(), NULL, NULL, NULL, + 22, 1, '系统菜单', 'FoodLabelingSystemMenu', 1, 'menu.management.system-menu', 'f0010030-0001-4000-8000-000000000030', + 'lucide:menu-square', '/system-menu', 0, 0, 1, NULL, '/food-labeling/management/system-menu/index', 0, NULL, ''), +('f0010033-0001-4000-8000-000000000033', 0, NOW(), NULL, NULL, NULL, + 23, 1, '菜单管理', 'FoodLabelingMenuManagement', 1, 'menu.management.menu', 'f0010030-0001-4000-8000-000000000030', + 'lucide:utensils', '/menu-management', 0, 0, 1, NULL, '/food-labeling/management/menu-management/index', 0, NULL, ''), +('f0010034-0001-4000-8000-000000000034', 0, NOW(), NULL, NULL, NULL, + 24, 1, '设备', 'FoodLabelingDevices', 1, 'menu.management.devices', 'f0010030-0001-4000-8000-000000000030', + 'lucide:smartphone', '/devices', 0, 0, 1, NULL, '/food-labeling/modules/devices/index', 0, NULL, ''), +('f0010035-0001-4000-8000-000000000035', 0, NOW(), NULL, NULL, NULL, + 25, 1, '报表', 'FoodLabelingReports', 1, 'menu.management.reports', 'f0010030-0001-4000-8000-000000000030', + 'lucide:file-bar-chart', '/reports', 0, 0, 1, NULL, '/food-labeling/management/reports/index', 0, NULL, ''), +('f0010036-0001-4000-8000-000000000036', 0, NOW(), NULL, NULL, NULL, + 26, 1, '发票', 'FoodLabelingInvoices', 1, 'menu.management.invoices', 'f0010030-0001-4000-8000-000000000030', + 'lucide:receipt', '/invoices', 0, 0, 1, NULL, '/food-labeling/modules/invoices/index', 0, NULL, ''), +('f0010037-0001-4000-8000-000000000037', 0, NOW(), NULL, NULL, NULL, + 27, 1, '二维码', 'FoodLabelingQrCodes', 1, 'menu.management.qr-codes', 'f0010030-0001-4000-8000-000000000030', + 'lucide:qr-code', '/qr-codes', 0, 0, 1, NULL, '/food-labeling/modules/qr-codes/index', 0, NULL, ''), +('f0010038-0001-4000-8000-000000000038', 0, NOW(), NULL, NULL, NULL, + 28, 1, '支持', 'FoodLabelingSupport', 1, 'menu.management.support', 'f0010030-0001-4000-8000-000000000030', + 'lucide:life-buoy', '/support', 0, 0, 1, NULL, '/food-labeling/management/support/index', 0, NULL, ''), +('f0010039-0001-4000-8000-000000000039', 0, NOW(), NULL, NULL, NULL, + 29, 1, 'API', 'FoodLabelingApi', 1, 'menu.management.api', 'f0010030-0001-4000-8000-000000000030', + 'lucide:code-2', '/api-settings', 0, 0, 1, NULL, '/food-labeling/modules/api/index', 0, NULL, ''); + +COMMIT; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/Auth/ThAppLoginInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/Auth/ThAppLoginInputVo.cs index e9ee4af..d47c2aa 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/Auth/ThAppLoginInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/Auth/ThAppLoginInputVo.cs @@ -3,13 +3,14 @@ using System.ComponentModel.DataAnnotations; namespace FoodLabeling.Th.Application.Contracts.Dtos.Auth; /// -/// 泰额版 App 登录(须指定租户,用户数据在租户独立库) +/// 泰额版 App 登录(用户数据在租户独立库;tenantId 可空,按邮箱反查公司) /// public class ThAppLoginInputVo { - /// 租户 Id(平台主库 yitenant.Id) - [Required] - public Guid TenantId { get; set; } + /// + /// 租户 Id(平台主库 YiTenant.Id)。可空:与 th-web-auth 一致,按邮箱/用户名反查公司租户。 + /// + public Guid? TenantId { get; set; } /// 登录邮箱 [Required] diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThProvisionTenantInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThProvisionTenantInputVo.cs index 0f71a8a..79826b0 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThProvisionTenantInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThProvisionTenantInputVo.cs @@ -7,10 +7,28 @@ namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; /// public class ThProvisionTenantInputVo { + /// 租户/公司名称(必填) [Required] public string Name { get; set; } = string.Empty; /// + /// 租户管理员登录邮箱(必填,可与 AdminUserName 二选一)。 + /// 作为该公司 Web 登录账号,全局唯一。 + /// + [EmailAddress] + public string? Email { get; set; } + + /// + /// 兼容旧前端字段:当作登录邮箱使用(与 Email 二选一,优先 Email)。 + /// + public string? AdminUserName { get; set; } + + /// + /// 可选:管理员初始明文密码;为空则使用系统配置 RbacOptions.AdminPassword。 + /// + public string? AdminPassword { get; set; } + + /// /// 可选:自定义连接串;为空则按 FoodLabeling:TenantDatabase 模板生成 /// public string? TenantConnectionString { get; set; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThProvisionTenantOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThProvisionTenantOutputDto.cs index f1802c4..dedf8d3 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThProvisionTenantOutputDto.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThProvisionTenantOutputDto.cs @@ -6,6 +6,9 @@ public class ThProvisionTenantOutputDto public string Name { get; set; } = string.Empty; + /// 租户管理员登录邮箱 + public string Email { get; set; } = string.Empty; + public string DatabaseName { get; set; } = string.Empty; public string TenantConnectionString { get; set; } = string.Empty; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs index 078acbc..8dd4e8b 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs @@ -1,11 +1,11 @@ namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; /// -/// SaaS 菜单权限树节点 +/// SaaS / 平台分配菜单树节点 /// public class ThSaasMenuPermissionTreeNodeDto { - /// 权限 Key(如 labeling:labels) + /// 菜单 Id(与主库/租户库 Menu.Id 一致) public string Key { get; set; } = string.Empty; /// 中文标题 diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs index 0ee4d6d..5a18130 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs @@ -8,6 +8,6 @@ public class ThUpdateCompanyMenusInputVo /// 租户 Id public Guid TenantId { get; set; } - /// 菜单权限 Key 列表(覆盖式) - public List MenuPermissionKeys { get; set; } = new(); + /// 菜单权限 Key 列表(覆盖式;传空数组表示清空) + public List? MenuPermissionKeys { get; set; } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/ITenantCompanyMenuSyncService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/ITenantCompanyMenuSyncService.cs new file mode 100644 index 0000000..de8d63e --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/ITenantCompanyMenuSyncService.cs @@ -0,0 +1,12 @@ +namespace FoodLabeling.Th.Application.Contracts.IServices; + +/// +/// 将主库公司菜单开通(fl_th_tenant_menu_permission)幂等同步到租户业务库 Menu / RoleMenu。 +/// +public interface ITenantCompanyMenuSyncService +{ + /// + /// 按主库开通记录,将对应菜单(含祖先节点)写入租户库并覆盖 admin 角色的 RoleMenu 绑定。 + /// + Task EnsureAdminRoleMenusSyncedAsync(Guid tenantId, CancellationToken cancellationToken = default); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThAppAuthAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThAppAuthAppService.cs index 5234d8d..de70769 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThAppAuthAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThAppAuthAppService.cs @@ -10,7 +10,8 @@ namespace FoodLabeling.Th.Application.Contracts.IServices; public interface IThAppAuthAppService : IApplicationService { /// - /// 登录:校验租户存在后,在租户业务库验证账号并签发 Token(Claim 含 TenantId) + /// 登录:校验租户存在后,在租户业务库验证账号并签发 Token(Claim 含 TenantId)。 + /// tenantId 可空,按邮箱/用户名反查公司租户(与 th-web-auth 一致)。 /// Task LoginAsync(ThAppLoginInputVo input); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs index 0a956aa..27cc08e 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs @@ -82,6 +82,16 @@ public interface IThMultiTenancyAppService : IApplicationService /// + /// 当前公司业务租户:获取已分配菜单权限(从 JWT / __tenant 解析租户 Id) + + /// + + Task GetMyCompanyMenusAsync(); + + + + /// + /// 平台级管理员:覆盖式设置公司(租户)菜单权限 /// diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThTenantProvisioningAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThTenantProvisioningAppService.cs index c94d161..dcecc7c 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThTenantProvisioningAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThTenantProvisioningAppService.cs @@ -9,12 +9,12 @@ namespace FoodLabeling.Th.Application.Contracts.IServices; public interface IThTenantProvisioningAppService : IApplicationService { /// - /// 在平台主库登记租户,并按配置生成/使用独立库连接串;可选触发后台建库建表 + /// 在平台主库登记租户;必须携带登录邮箱(唯一);可选触发后台建库建表并把管理员账号同步为该邮箱 /// Task ProvisionAsync(ThProvisionTenantInputVo input); /// - /// 对已有租户同步执行业务库 CodeFirst(建库 + 业务表 + Seed);耗时较长,已配置 10 分钟请求超时 + /// 对已有租户同步执行业务库 CodeFirst(建库 + 业务表 + Seed),并同步管理员登录邮箱;耗时较长,已配置请求超时 /// - Task InitializeTenantDatabaseAsync(Guid tenantId); + Task InitializeTenantDatabaseAsync(Guid tenantId, Guid? tenantIdQuery = null); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Options/FoodLabelingThTenantDatabaseOptions.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Options/FoodLabelingThTenantDatabaseOptions.cs index 6bc934a..86403ec 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Options/FoodLabelingThTenantDatabaseOptions.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Options/FoodLabelingThTenantDatabaseOptions.cs @@ -21,4 +21,9 @@ public class FoodLabelingThTenantDatabaseOptions public string Password { get; set; } = string.Empty; public string CharSet { get; set; } = "utf8mb4"; + + /// + /// 新建租户业务库默认排序规则(与 US 业务库一致) + /// + public string Collation { get; set; } = "utf8mb4_general_ci"; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Filters/FoodLabelingBusinessTenantActionFilter.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Filters/FoodLabelingBusinessTenantActionFilter.cs new file mode 100644 index 0000000..75887aa --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Filters/FoodLabelingBusinessTenantActionFilter.cs @@ -0,0 +1,113 @@ +using FoodLabeling.Application.Helpers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Application.Filters; + +/// +/// 泰额 SaaS:业务 AppService(fl_* / 租户 menu 等)须已解析租户上下文,禁止落到 host 主库。 +/// 平台登录误调业务接口时返回友好错误,避免 fl_label 缺表 500。 +/// +public class FoodLabelingBusinessTenantActionFilter : IAsyncActionFilter, ITransientDependency +{ + private readonly ICurrentTenant _currentTenant; + private readonly DbConnOptions _dbConnOptions; + private readonly ISqlSugarDbContext _dbContext; + + public FoodLabelingBusinessTenantActionFilter( + ICurrentTenant currentTenant, + IOptions dbConnOptions, + ISqlSugarDbContext dbContext) + { + _currentTenant = currentTenant; + _dbConnOptions = dbConnOptions.Value; + _dbContext = dbContext; + } + + public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (!_dbConnOptions.EnabledSaasMultiTenancy) + { + return next(); + } + + var path = context.HttpContext.Request.Path.Value ?? string.Empty; + if (!RequiresBusinessTenant(path)) + { + return next(); + } + + TenantContextGuard.EnsureBusinessTenantIfSaas(_currentTenant, _dbConnOptions, ResolveOperationLabel(path)); + TenantContextGuard.EnsureNotHostDatabaseIfSaas( + _dbContext, + _dbConnOptions, + ResolveOperationLabel(path)); + + return next(); + } + + /// + /// 平台主库接口(yitenant / 登录 / 开户等)不要求业务租户上下文。 + /// + internal static bool RequiresBusinessTenant(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + if (!path.StartsWith("/api/app", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var lower = path.ToLowerInvariant(); + foreach (var fragment in PlatformPathFragments) + { + if (lower.Contains(fragment, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private static string ResolveOperationLabel(string path) + { + var segment = path.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); + return string.IsNullOrWhiteSpace(segment) ? "业务接口" : segment; + } + + /// 平台侧路径片段(小写),命中则跳过业务租户校验。 + private static readonly string[] PlatformPathFragments = + { + "/th-web-auth", + "/th-app-auth", + "/us-app-auth", + "/th-multi-tenancy", + "/th-tenant-provisioning", + "/th-tenant-select", + "/th-rbac", + // 平台管理员登录后拉主库菜单;__tenant 为空/全 0 时不可当业务接口拦截 + "/auth-session", + // 平台登录可进 Dashboard:服务内返回空统计 + "/dashboard", + // 平台主库租户 CRUD(/api/app/tenant),勿与 th-tenant-* 混淆 + "/api/app/tenant", + "/account", + "/oauth", + "/captcha", + "/forgot-password", + "/authorization", + "/login", + "/logout", + "/wwwroot", + "/hangfire", + "/demo", + "/food-label-demo", + }; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/FoodLabelingThApplicationModule.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/FoodLabelingThApplicationModule.cs index fdfa398..6dd6798 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/FoodLabelingThApplicationModule.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/FoodLabelingThApplicationModule.cs @@ -1,7 +1,9 @@ using FoodLabeling.Application; using FoodLabeling.Th.Application.Contracts; using FoodLabeling.Th.Application.Contracts.Options; +using FoodLabeling.Th.Application.Filters; using FoodLabeling.Th.Domain; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Yi.Framework.Ddd.Application; @@ -31,5 +33,10 @@ public class FoodLabelingThApplicationModule : AbpModule Configure( configuration.GetSection(FoodLabelingThTenantSelectCryptoOptions.SectionName)); + + Configure(options => + { + options.Filters.AddService(); + }); } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/FoodLabelingThTenantDatabaseMigrationContributor.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/FoodLabelingThTenantDatabaseMigrationContributor.cs new file mode 100644 index 0000000..8b88bc8 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/FoodLabelingThTenantDatabaseMigrationContributor.cs @@ -0,0 +1,55 @@ +using FoodLabeling.Application.MultiTenancy; +using Microsoft.Extensions.Logging; +using Volo.Abp.DependencyInjection; +using Yi.Framework.TenantManagement.Application.Contracts; + +namespace FoodLabeling.Th.Application.MultiTenancy; + +/// +/// 泰额版:新租户 CodeFirst 后自动执行业务库补充 SQL(AppliedRegionType、Partner scope、fl_team_member_scope 等)。 +/// +public class FoodLabelingThTenantDatabaseMigrationContributor + : ITenantDatabaseMigrationContributor, ITransientDependency +{ + private readonly ILogger _logger; + + public FoodLabelingThTenantDatabaseMigrationContributor( + ILogger logger) + { + _logger = logger; + } + + /// + public int Order => 0; + + /// + public async Task ApplyAsync( + TenantDatabaseMigrationContext context, + CancellationToken cancellationToken = default) + { + if (!TenantSqlScriptExecutor.Supports(context.DbType)) + { + _logger.LogInformation( + "跳过租户 {TenantId} 业务库 SQL 迁移:DbType={DbType} 非 MySQL", + context.TenantId, + context.DbType); + return; + } + + var scriptAssembly = typeof(FoodLabelingTenantMigrationScriptNames).Assembly; + + foreach (var resourceName in FoodLabelingTenantMigrationScriptNames.TenantInitializationOrder) + { + _logger.LogInformation( + "租户 {TenantId} 执行业务库迁移脚本 {Script}", + context.TenantId, + resourceName); + + var sql = EmbeddedTenantSqlScripts.Read(scriptAssembly, resourceName); + await TenantSqlScriptExecutor.ExecuteMySqlScriptAsync( + context.ConnectionString, + sql, + cancellationToken); + } + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/IThTenantDatabaseBackgroundInitializer.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/IThTenantDatabaseBackgroundInitializer.cs index 5c8f639..54c51d1 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/IThTenantDatabaseBackgroundInitializer.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/IThTenantDatabaseBackgroundInitializer.cs @@ -9,4 +9,9 @@ public interface IThTenantDatabaseBackgroundInitializer /// 将初始化任务入队;同一租户并发仅执行一次。 /// void Enqueue(Guid tenantId); + + /// + /// 该租户是否正在后台初始化业务库。 + /// + bool IsRunning(Guid tenantId); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantAdminAccountBootstrapper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantAdminAccountBootstrapper.cs new file mode 100644 index 0000000..cda43e3 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantAdminAccountBootstrapper.cs @@ -0,0 +1,372 @@ +using FoodLabeling.Th.Application.Contracts.Options; +using FoodLabeling.Th.Domain.Entities; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SqlSugar; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Yi.Framework.Rbac.Domain.Entities; +using Yi.Framework.Rbac.Domain.Helpers; +using Yi.Framework.Rbac.Domain.Shared.Consts; +using Yi.Framework.Rbac.Domain.Shared.Options; +using Yi.Framework.SqlSugarCore.Abstractions; +using Yi.Framework.TenantManagement.Domain; + +namespace FoodLabeling.Th.Application.MultiTenancy; + +/// +/// 新增租户后:将业务库默认 admin 账号同步为开通时填写的登录邮箱,并确保绑定 admin 角色。 +/// +public class TenantAdminAccountBootstrapper : ITransientDependency +{ + private readonly TenantSelectCredentialCipher _credentialCipher; + private readonly FoodLabelingThTenantDatabaseOptions _tenantDatabaseOptions; + private readonly DbConnOptions _dbConnOptions; + private readonly RbacOptions _rbacOptions; + private readonly ILogger _logger; + + public TenantAdminAccountBootstrapper( + TenantSelectCredentialCipher credentialCipher, + IOptions tenantDatabaseOptions, + IOptions dbConnOptions, + IOptions rbacOptions, + ILogger logger) + { + _credentialCipher = credentialCipher; + _tenantDatabaseOptions = tenantDatabaseOptions.Value; + _dbConnOptions = dbConnOptions.Value; + _rbacOptions = rbacOptions.Value; + _logger = logger; + } + + /// + /// 按主库凭据把租户库默认 admin 的 UserName/Email(及可选密码)同步为登录邮箱, + /// 并幂等绑定 admin 角色(ApplyLoginEmail 将 UserName 从 admin 改为邮箱后,必须写入 userrole)。 + /// + public async Task ApplyLoginEmailAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + var tenant = await ThTenantHostDataAccessor.LoadTenantAsync(_dbConnOptions, tenantId); + if (tenant is null) + { + _logger.LogWarning("租户 {TenantId} 不存在,跳过管理员邮箱同步", tenantId); + return; + } + + var credential = await ThTenantHostDataAccessor.LoadCredentialAsync(_dbConnOptions, tenantId); + var loginEmail = credential?.LoginAccount?.Trim(); + + var (connectionString, dbType) = await TenantBusinessDatabaseAccessor.ResolveConnectionAsync( + tenant, + _tenantDatabaseOptions, + async t => + { + await PersistTenantConnectionAsync(t); + }); + + using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); + try + { + TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, tenantId); + } + catch (UserFriendlyException ex) + { + _logger.LogWarning(ex, "租户 {TenantId} 业务库未就绪,跳过管理员邮箱同步", tenantId); + return; + } + + if (string.IsNullOrWhiteSpace(loginEmail) + || !ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(loginEmail)) + { + _logger.LogInformation( + "租户 {TenantId} 无有效登录邮箱凭据,保留 Seed 默认 admin", + tenantId); + await EnsureAdminRoleBoundAsync(tenantDb, tenantId, null, cancellationToken); + return; + } + + string? plainPassword = null; + if (credential != null + && !string.IsNullOrEmpty(credential.PasswordCipher) + && !string.IsNullOrEmpty(credential.PasswordIv)) + { + try + { + plainPassword = _credentialCipher.DecryptPassword( + credential.PasswordCipher, + credential.PasswordIv); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "租户 {TenantId} 凭据密码解密失败,仅同步账号", tenantId); + } + } + + plainPassword ??= _rbacOptions.AdminPassword; + + var normalizedEmail = loginEmail.Trim(); + var normalizedLower = normalizedEmail.ToLowerInvariant(); + var companyDisplayName = ResolveCompanyDisplayName(tenant); + + var alreadySynced = await tenantDb.Queryable() + .Where(u => !u.IsDeleted) + .Where(u => SqlFunc.ToLower(u.UserName) == normalizedLower + || (u.Email != null && SqlFunc.ToLower(u.Email) == normalizedLower)) + .Take(1) + .ToListAsync(cancellationToken); + var syncedUser = alreadySynced.FirstOrDefault(); + if (syncedUser != null) + { + await EnsureCompanyAdminProfileAsync( + tenantDb, + syncedUser, + companyDisplayName, + plainPassword, + cancellationToken); + + _logger.LogInformation( + "租户 {TenantId} 管理员登录账号已是 {LoginEmail},无需再改名", + tenantId, + normalizedEmail); + await EnsureAdminRoleBoundAsync(tenantDb, tenantId, normalizedEmail, cancellationToken); + return; + } + + var admins = await tenantDb.Queryable() + .Where(u => !u.IsDeleted) + .Where(u => u.UserName == UserConst.Admin + || (u.Email != null && SqlFunc.ToLower(u.Email) == "admin@example.com")) + .OrderBy(u => u.OrderNum, OrderByType.Desc) + .Take(1) + .ToListAsync(cancellationToken); + var adminUser = admins.FirstOrDefault(); + + if (adminUser is null) + { + _logger.LogError( + "租户 {TenantId} 未找到默认 admin 用户,无法将登录账号同步为 {LoginEmail}", + tenantId, + normalizedEmail); + throw new UserFriendlyException( + "租户业务库默认管理员账号缺失,无法同步登录邮箱,请重新初始化业务库或联系运维"); + } + + var renamed = await tenantDb.Updateable() + .SetColumns(u => u.UserName == normalizedEmail) + .SetColumns(u => u.Email == normalizedEmail) + .SetColumns(u => u.Name == companyDisplayName) + .SetColumns(u => u.Nick == companyDisplayName) + .Where(u => u.Id == adminUser.Id) + .ExecuteCommandAsync(cancellationToken); + if (renamed <= 0) + { + throw new UserFriendlyException( + "租户业务库管理员账号同步失败(UserName/Email 未更新),请重试 initialize-tenant-database 或联系运维"); + } + + if (!string.IsNullOrEmpty(plainPassword)) + { + UserPasswordHelper.ApplyPlainPassword(adminUser, plainPassword); + var passwordUpdated = await tenantDb.Updateable() + .SetColumns(u => u.EncryPassword.Password == adminUser.EncryPassword.Password) + .SetColumns(u => u.EncryPassword.Salt == adminUser.EncryPassword.Salt) + .Where(u => u.Id == adminUser.Id) + .ExecuteCommandAsync(cancellationToken); + if (passwordUpdated <= 0) + { + _logger.LogWarning("租户 {TenantId} 管理员密码列未更新,账号已改为 {LoginEmail}", tenantId, normalizedEmail); + } + } + + _logger.LogInformation( + "租户 {TenantId} 管理员登录账号已同步为 {LoginEmail}", + tenantId, + normalizedEmail); + await EnsureAdminRoleBoundAsync(tenantDb, tenantId, normalizedEmail, cancellationToken); + } + + private static string ResolveCompanyDisplayName(TenantAggregateRoot tenant) + { + var name = tenant.Name?.Trim(); + return string.IsNullOrWhiteSpace(name) ? "公司管理员" : name; + } + + private static async Task EnsureCompanyAdminProfileAsync( + ISqlSugarClient tenantDb, + UserAggregateRoot user, + string companyDisplayName, + string? plainPassword, + CancellationToken cancellationToken) + { + var shouldRefreshName = string.IsNullOrWhiteSpace(user.Name) + || string.Equals(user.Name, "超级管理员", StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(user.Nick) + || string.Equals(user.Nick, "超级管理员", StringComparison.Ordinal); + + if (!string.IsNullOrEmpty(plainPassword)) + { + UserPasswordHelper.ApplyPlainPassword(user, plainPassword); + } + + if (!shouldRefreshName && string.IsNullOrEmpty(plainPassword)) + { + return; + } + + var updater = tenantDb.Updateable().Where(u => u.Id == user.Id); + if (shouldRefreshName) + { + updater = updater + .SetColumns(u => u.Name == companyDisplayName) + .SetColumns(u => u.Nick == companyDisplayName); + } + + if (!string.IsNullOrEmpty(plainPassword)) + { + updater = updater + .SetColumns(u => u.EncryPassword.Password == user.EncryPassword.Password) + .SetColumns(u => u.EncryPassword.Salt == user.EncryPassword.Salt); + } + + await updater.ExecuteCommandAsync(cancellationToken); + } + + /// + /// 幂等确保租户业务库管理员用户已绑定 RoleCode=admin 的角色。 + /// + public async Task EnsureTenantAdminRoleAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + var tenant = await ThTenantHostDataAccessor.LoadTenantAsync(_dbConnOptions, tenantId); + if (tenant is null) + { + return; + } + + var credential = await ThTenantHostDataAccessor.LoadCredentialAsync(_dbConnOptions, tenantId); + var loginEmail = credential?.LoginAccount?.Trim(); + + var (connectionString, dbType) = await TenantBusinessDatabaseAccessor.ResolveConnectionAsync( + tenant, + _tenantDatabaseOptions, + async t => + { + await PersistTenantConnectionAsync(t); + }); + + using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); + try + { + TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, tenantId); + } + catch (UserFriendlyException ex) + { + _logger.LogWarning(ex, "租户 {TenantId} 业务库未就绪,跳过 admin 角色绑定", tenantId); + return; + } + + await EnsureAdminRoleBoundAsync(tenantDb, tenantId, loginEmail, cancellationToken); + } + + /// + /// 读取主库开通邮箱(供登录前探测等场景,不依赖仓储 FindAsync)。 + /// + public async Task GetProvisionedLoginAccountAsync(Guid tenantId) + { + var credential = await ThTenantHostDataAccessor.LoadCredentialAsync(_dbConnOptions, tenantId); + var account = credential?.LoginAccount?.Trim(); + return string.IsNullOrWhiteSpace(account) ? null : account; + } + + /// + /// 查找管理员用户并绑定 admin 角色;已有绑定则跳过。 + /// + private async Task EnsureAdminRoleBoundAsync( + ISqlSugarClient tenantDb, + Guid tenantId, + string? provisionedLoginEmail, + CancellationToken cancellationToken) + { + var adminRoles = await tenantDb.Queryable() + .Where(r => !r.IsDeleted) + .Where(r => r.RoleCode == UserConst.AdminRolesCode || r.RoleCode == UserConst.Admin) + .OrderBy(r => r.OrderNum, OrderByType.Desc) + .Take(1) + .ToListAsync(cancellationToken); + var adminRole = adminRoles.FirstOrDefault(); + if (adminRole is null) + { + _logger.LogWarning("租户 {TenantId} 未找到 RoleCode=admin 角色,跳过 userrole 绑定", tenantId); + return; + } + + UserAggregateRoot? adminUser = null; + if (!string.IsNullOrWhiteSpace(provisionedLoginEmail) + && ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(provisionedLoginEmail)) + { + var normalizedLower = provisionedLoginEmail.Trim().ToLowerInvariant(); + var provisionedUsers = await tenantDb.Queryable() + .Where(u => !u.IsDeleted) + .Where(u => SqlFunc.ToLower(u.UserName) == normalizedLower + || (u.Email != null && SqlFunc.ToLower(u.Email) == normalizedLower)) + .Take(1) + .ToListAsync(cancellationToken); + adminUser = provisionedUsers.FirstOrDefault(); + } + + if (adminUser is null) + { + var seedAdmins = await tenantDb.Queryable() + .Where(u => !u.IsDeleted) + .Where(u => u.UserName == UserConst.Admin + || u.Nick == "超级管理员") + .OrderBy(u => u.OrderNum, OrderByType.Desc) + .Take(1) + .ToListAsync(cancellationToken); + adminUser = seedAdmins.FirstOrDefault(); + } + + if (adminUser is null) + { + _logger.LogWarning("租户 {TenantId} 未找到管理员用户,跳过 userrole 绑定", tenantId); + return; + } + + var alreadyBound = await tenantDb.Queryable() + .Where(ur => ur.UserId == adminUser.Id && ur.RoleId == adminRole.Id) + .AnyAsync(); + if (alreadyBound) + { + return; + } + + var entity = new UserRoleEntity + { + UserId = adminUser.Id, + RoleId = adminRole.Id + }; + await tenantDb.Insertable(entity).ExecuteCommandAsync(cancellationToken); + + _logger.LogInformation( + "租户 {TenantId} 已为管理员 {UserName} 绑定 admin 角色", + tenantId, + adminUser.UserName); + } + + private async Task PersistTenantConnectionAsync(TenantAggregateRoot tenant) + { + if (string.IsNullOrWhiteSpace(_dbConnOptions.Url)) + { + throw new UserFriendlyException("未配置主库连接字符串 DbConnOptions.Url"); + } + + var dbType = _dbConnOptions.DbType ?? DbType.MySql; + using var client = new SqlSugarClient(new ConnectionConfig + { + ConfigId = $"th-host-write-yitenant-{Guid.NewGuid():N}", + DbType = dbType, + ConnectionString = _dbConnOptions.Url, + IsAutoCloseConnection = true + }); + + await client.Updateable(tenant).ExecuteCommandAsync(); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantBusinessDatabaseAccessor.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantBusinessDatabaseAccessor.cs index 0eecf72..3b43fab 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantBusinessDatabaseAccessor.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantBusinessDatabaseAccessor.cs @@ -58,7 +58,22 @@ public static class TenantBusinessDatabaseAccessor ConfigId = $"th-tenant-direct-{Guid.NewGuid():N}", DbType = dbType, ConnectionString = connectionString, - IsAutoCloseConnection = true + IsAutoCloseConnection = true, + ConfigureExternalServices = new ConfigureExternalServices + { + EntityService = (propertyInfo, columnInfo) => + { + if (propertyInfo.PropertyType == typeof(Volo.Abp.Data.ExtraPropertyDictionary)) + { + columnInfo.IsIgnore = true; + } + + if (propertyInfo.Name == nameof(Volo.Abp.Domain.Entities.Entity.Id)) + { + columnInfo.IsPrimarykey = true; + } + } + } }); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantCompanyMenuScopeHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantCompanyMenuScopeHelper.cs new file mode 100644 index 0000000..aef27ca --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantCompanyMenuScopeHelper.cs @@ -0,0 +1,45 @@ +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Application.MultiTenancy; + +/// +/// 从主库 fl_th_tenant_menu_permission 解析当前公司已开通菜单 Id(含 legacy key 归一化)。 +/// +internal static class TenantCompanyMenuScopeHelper +{ + internal static async Task> LoadEnabledMenuIdsAsync( + DbConnOptions dbConnOptions, + Guid tenantId) + { + var rawKeys = await ThTenantHostDataAccessor.LoadMenuPermissionKeysAsync(dbConnOptions, tenantId); + var hostMenus = await ThTenantHostDataAccessor.LoadHostMenusAsync(dbConnOptions); + var permissionCodeMap = hostMenus + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); + + return ThSaasMenuPermissionCatalog.NormalizeToMenuIds(rawKeys, permissionCodeMap) + .Where(assignableIds.Contains) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// 公司已开通菜单及其祖先节点 Id(角色绑定校验用)。 + /// + internal static async Task> LoadAllowedMenuIdsWithAncestorsAsync( + DbConnOptions dbConnOptions, + Guid tenantId) + { + var enabledMenuIds = await LoadEnabledMenuIdsAsync(dbConnOptions, tenantId); + if (enabledMenuIds.Count == 0) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + var hostMenus = await ThTenantHostDataAccessor.LoadHostMenusAsync(dbConnOptions); + return ThSaasMenuPermissionCatalog.FilterMenusWithAncestors(hostMenus, enabledMenuIds) + .Select(m => m.Id.ToString()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantCompanyMenuSyncService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantCompanyMenuSyncService.cs new file mode 100644 index 0000000..9d9d40a --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantCompanyMenuSyncService.cs @@ -0,0 +1,282 @@ +using FoodLabeling.Th.Application.Contracts.IServices; +using FoodLabeling.Th.Application.Contracts.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SqlSugar; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Guids; +using Yi.Framework.Rbac.Domain.Entities; +using Yi.Framework.Rbac.Domain.Shared; +using Yi.Framework.Rbac.Domain.Shared.Consts; +using Yi.Framework.SqlSugarCore.Abstractions; +using Yi.Framework.TenantManagement.Domain; + +namespace FoodLabeling.Th.Application.MultiTenancy; + +/// +/// 主库 fl_th_tenant_menu_permission 同步到租户业务库 menu 与 admin 角色 rolemenu。 +/// 主库使用稳定 f001 菜单 Id;租户 Seed 为随机 Guid,须先 upsert 主库菜单副本再写 RoleMenu。 +/// 主库读取使用 DbConnOptions.Url 独立 SqlSugarClient,避免后台 Init Scope 释放后仓储连接 ObjectDisposed。 +/// +public class TenantCompanyMenuSyncService : ITenantCompanyMenuSyncService, ITransientDependency +{ + private static readonly Guid ProtectedDefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + private readonly DbConnOptions _dbConnOptions; + private readonly FoodLabelingThTenantDatabaseOptions _tenantDatabaseOptions; + private readonly IGuidGenerator _guidGenerator; + private readonly ILogger _logger; + + public TenantCompanyMenuSyncService( + IOptions dbConnOptions, + IOptions tenantDatabaseOptions, + IGuidGenerator guidGenerator, + ILogger logger) + { + _dbConnOptions = dbConnOptions.Value; + _tenantDatabaseOptions = tenantDatabaseOptions.Value; + _guidGenerator = guidGenerator; + _logger = logger; + } + + /// + public async Task EnsureAdminRoleMenusSyncedAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + if (tenantId == Guid.Empty || tenantId == ProtectedDefaultTenantId) + { + return; + } + + var tenant = await ThTenantHostDataAccessor.LoadTenantAsync(_dbConnOptions, tenantId) + ?? throw new UserFriendlyException("租户不存在"); + + var menuIds = await LoadNormalizedHostMenuIdsAsync(tenantId); + var (connectionString, dbType) = await TenantBusinessDatabaseAccessor.ResolveConnectionAsync( + tenant, + _tenantDatabaseOptions, + PersistTenantConnectionAsync); + + using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); + try + { + TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, tenantId); + } + catch (UserFriendlyException ex) + { + _logger.LogWarning(ex, "租户 {TenantId} 业务库未就绪,跳过公司菜单同步", tenantId); + return; + } + + var adminRole = await FindAdminRoleAsync(tenantDb, cancellationToken); + if (adminRole is null) + { + _logger.LogWarning("租户 {TenantId} 未找到 admin 角色,跳过公司菜单同步", tenantId); + return; + } + + if (menuIds.Count == 0) + { + await tenantDb.Deleteable() + .Where(x => x.RoleId == adminRole.Id) + .ExecuteCommandAsync(cancellationToken); + return; + } + + var hostMenus = await ThTenantHostDataAccessor.LoadHostMenusAsync(_dbConnOptions); + var menusToUpsert = CollectMenusWithAncestors(hostMenus, menuIds); + await UpsertTenantMenusAsync(tenantDb, menusToUpsert, cancellationToken); + await ReplaceAdminRoleMenusAsync(tenantDb, adminRole.Id, menusToUpsert.Select(m => m.Id).ToList(), cancellationToken); + + _logger.LogInformation( + "租户 {TenantId} 已同步 {MenuCount} 条公司菜单到 admin 角色", + tenantId, + menusToUpsert.Count); + } + + private async Task> LoadNormalizedHostMenuIdsAsync(Guid tenantId) + { + var rawKeys = await ThTenantHostDataAccessor.LoadMenuPermissionKeysAsync(_dbConnOptions, tenantId); + + var hostMenus = await ThTenantHostDataAccessor.LoadHostMenusAsync(_dbConnOptions); + var permissionCodeMap = hostMenus + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); + + return ThSaasMenuPermissionCatalog.NormalizeToMenuIds(rawKeys, permissionCodeMap) + .Where(assignableIds.Contains) + .Select(x => Guid.TryParse(x, out var id) ? id : Guid.Empty) + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); + } + + private static List CollectMenusWithAncestors( + IReadOnlyList allHostMenus, + IReadOnlyCollection menuIds) + { + var byId = allHostMenus.ToDictionary(m => m.Id); + var collected = new HashSet(); + var result = new List(); + + foreach (var menuId in menuIds) + { + var currentId = menuId; + while (currentId != Guid.Empty && byId.TryGetValue(currentId, out var menu)) + { + if (!collected.Add(currentId)) + { + break; + } + + result.Add(menu); + if (MenuParentIdConverter.IsRoot(menu.ParentId)) + { + break; + } + + currentId = MenuParentIdConverter.ToGuid(menu.ParentId); + } + } + + return result + .OrderBy(m => m.OrderNum) + .ThenBy(m => m.MenuName) + .ToList(); + } + + private static async Task FindAdminRoleAsync( + ISqlSugarClient tenantDb, + CancellationToken cancellationToken) + { + var adminRoles = await tenantDb.Queryable() + .Where(r => !r.IsDeleted) + .Where(r => r.RoleCode == UserConst.AdminRolesCode || r.RoleCode == UserConst.Admin) + .OrderBy(r => r.OrderNum) + .Take(1) + .ToListAsync(cancellationToken); + return adminRoles.FirstOrDefault(); + } + + private static async Task UpsertTenantMenusAsync( + ISqlSugarClient tenantDb, + IReadOnlyList hostMenus, + CancellationToken cancellationToken) + { + if (hostMenus.Count == 0) + { + return; + } + + var ids = hostMenus.Select(m => m.Id).ToList(); + var existingIds = await tenantDb.Queryable() + .Where(m => ids.Contains(m.Id)) + .Select(m => m.Id) + .ToListAsync(cancellationToken); + var existingSet = existingIds.ToHashSet(); + + var toInsert = new List(); + var toUpdate = new List(); + foreach (var source in hostMenus) + { + var clone = CloneMenuForTenant(source); + if (existingSet.Contains(source.Id)) + { + toUpdate.Add(clone); + } + else + { + toInsert.Add(clone); + } + } + + if (toInsert.Count > 0) + { + await tenantDb.Insertable(toInsert).ExecuteCommandAsync(cancellationToken); + } + + if (toUpdate.Count > 0) + { + await tenantDb.Updateable(toUpdate) + .IgnoreColumns(m => new { m.CreationTime, m.CreatorId }) + .ExecuteCommandAsync(cancellationToken); + } + } + + private static MenuAggregateRoot CloneMenuForTenant(MenuAggregateRoot source) + { + return new MenuAggregateRoot(source.Id) + { + IsDeleted = false, + CreationTime = source.CreationTime, + CreatorId = source.CreatorId, + LastModifierId = source.LastModifierId, + LastModificationTime = DateTime.Now, + OrderNum = source.OrderNum, + State = source.State, + MenuName = source.MenuName, + RouterName = source.RouterName, + MenuType = source.MenuType, + PermissionCode = source.PermissionCode, + ParentId = source.ParentId, + MenuIcon = source.MenuIcon, + Router = source.Router, + IsLink = source.IsLink, + IsCache = source.IsCache, + IsShow = source.IsShow, + Remark = source.Remark, + Component = source.Component, + MenuSource = source.MenuSource, + Query = source.Query, + ConcurrencyStamp = source.ConcurrencyStamp + }; + } + + private async Task ReplaceAdminRoleMenusAsync( + ISqlSugarClient tenantDb, + Guid adminRoleId, + IReadOnlyList menuIds, + CancellationToken cancellationToken) + { + await tenantDb.Deleteable() + .Where(x => x.RoleId == adminRoleId) + .ExecuteCommandAsync(cancellationToken); + + if (menuIds.Count == 0) + { + return; + } + + var existMenuIds = await tenantDb.Queryable() + .Where(x => !x.IsDeleted) + .Where(x => menuIds.Contains(x.Id)) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + if (existMenuIds.Count == 0) + { + return; + } + + var entities = existMenuIds.Select(menuId => + { + var entity = new RoleMenuEntity + { + RoleId = adminRoleId, + MenuId = menuId + }; + EntityHelper.TrySetId(entity, () => _guidGenerator.Create()); + return entity; + }).ToList(); + + await tenantDb.Insertable(entities).ExecuteCommandAsync(cancellationToken); + } + + private async Task PersistTenantConnectionAsync(TenantAggregateRoot tenant) + { + await ThTenantHostDataAccessor.UpdateTenantAsync(_dbConnOptions, tenant); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseBootstrapper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseBootstrapper.cs index 971f72b..67628e3 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseBootstrapper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseBootstrapper.cs @@ -56,7 +56,7 @@ public static class TenantDatabaseBootstrapper $"创建租户库失败(库={databaseName})。尝试结果:{string.Join(" | ", createErrors)}。" + $"说明:MySQL 报 Access denied to database 新建库名时,通常是账号没有 CREATE 权限(与能否连上主库无关)。" + $"请在 appsettings 的 DbConnOptions.AdminConnectionString 配置高权限账号连接串后重试;或手动执行:" + - $"CREATE DATABASE IF NOT EXISTS `{databaseName}` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; " + + $"{TenantDatabaseCollation.BuildCreateDatabaseSql(databaseName)}; " + $"GRANT ALL PRIVILEGES ON `{databaseName}`.* TO '{userId}'@'%'; " + $"再调用 initialize-tenant-database 补跑建表与 Seed"); } @@ -114,9 +114,7 @@ public static class TenantDatabaseBootstrapper IsAutoCloseConnection = true }); - createDb.Ado.ExecuteCommand( - $"CREATE DATABASE IF NOT EXISTS `{databaseName}` " + - "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + createDb.Ado.ExecuteCommand(TenantDatabaseCollation.BuildCreateDatabaseSql(databaseName)); } /// diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseCollation.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseCollation.cs new file mode 100644 index 0000000..74474f5 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseCollation.cs @@ -0,0 +1,15 @@ +namespace FoodLabeling.Th.Application.MultiTenancy; + +/// +/// 新建租户业务库统一字符集 / 排序规则(与 antis-foodlabeling-us 一致)。 +/// +public static class TenantDatabaseCollation +{ + public const string CharSet = "utf8mb4"; + + public const string Collation = "utf8mb4_general_ci"; + + public static string BuildCreateDatabaseSql(string databaseName) => + $"CREATE DATABASE IF NOT EXISTS `{databaseName}` " + + $"DEFAULT CHARACTER SET {CharSet} COLLATE {Collation}"; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseConnectionStringBuilder.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseConnectionStringBuilder.cs index 6ec46ce..7d4a5de 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseConnectionStringBuilder.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseConnectionStringBuilder.cs @@ -34,6 +34,16 @@ public static class TenantDatabaseConnectionStringBuilder return key; } + /// + /// 开通时自动生成唯一库名片段:稳定名称 key + 8 位随机后缀,避免同名公司共用业务库。 + /// + public static string BuildUniqueTenantDatabaseKey(string tenantName) + { + var baseKey = NormalizeTenantDatabaseKey(tenantName); + var suffix = Guid.NewGuid().ToString("N")[..8]; + return $"{baseKey}_{suffix}"; + } + public static string BuildDatabaseName( FoodLabelingThTenantDatabaseOptions options, string tenantName, diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThLoginTenantResolver.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThLoginTenantResolver.cs new file mode 100644 index 0000000..98fb4af --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThLoginTenantResolver.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Logging; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Application.MultiTenancy; + +/// +/// 泰额 Web / App 登录共用:未选公司时按开通邮箱或租户业务库 user 反查公司租户。 +/// +internal static class ThLoginTenantResolver +{ + /// + /// 未选公司时:先查开通管理员凭据,再扫描各租户业务库 user(Team Member 等)。 + /// + internal static async Task TryResolveCompanyTenantIdByLoginAsync( + DbConnOptions dbConnOptions, + string? loginAccount, + ILogger? logger = null) + { + if (string.IsNullOrWhiteSpace(loginAccount)) + { + return null; + } + + try + { + if (ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(loginAccount)) + { + var fromCredential = await ThTenantHostDataAccessor.FindActiveTenantIdByLoginAccountAsync( + dbConnOptions, + loginAccount); + if (fromCredential.HasValue) + { + return fromCredential; + } + } + + return await ThTenantHostDataAccessor.FindActiveTenantIdByBusinessUserLoginAsync( + dbConnOptions, + loginAccount); + } + catch (Exception ex) + { + logger?.LogError(ex, "按登录账号反查公司租户失败:{UserName}", loginAccount); + return null; + } + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs index d2b1c16..f37628a 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs @@ -1,106 +1,231 @@ using FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; +using Yi.Framework.Rbac.Domain.Entities; namespace FoodLabeling.Th.Application.MultiTenancy; /// -/// 泰额版 SaaS 菜单权限目录(与前端 saas-menu-tree 一致) +/// 平台可分配给公司的菜单:以主库 Menu 为准;并兼容历史 SaaS Key → 菜单 Id 映射。 /// public static class ThSaasMenuPermissionCatalog { - private static readonly Lazy> TreeLazy = - new(BuildTree); - - private static readonly Lazy> AllKeysLazy = - new(() => new HashSet(CollectAllKeys(TreeLazy.Value), StringComparer.OrdinalIgnoreCase)); - /// - /// 菜单权限树 + /// 历史静态 SaaS Key → 固定菜单 Guid(th-tenant-menu-seed) /// - public static IReadOnlyList Tree => TreeLazy.Value; + private static readonly Dictionary LegacyKeyToMenuId = + new(StringComparer.OrdinalIgnoreCase) + { + ["dashboard"] = "f0010001-0001-4000-8000-000000000001", + ["dashboard:analytics"] = "f0010001-0001-4000-8000-000000000001", + ["labeling"] = "f0010010-0001-4000-8000-000000000010", + ["labeling:labels"] = "f0010011-0001-4000-8000-000000000011", + ["labeling:categories"] = "f0010012-0001-4000-8000-000000000012", + ["labeling:types"] = "f0010013-0001-4000-8000-000000000013", + ["labeling:templates"] = "f0010014-0001-4000-8000-000000000014", + ["labeling:multiple-options"] = "f0010015-0001-4000-8000-000000000015", + ["modules"] = "f0010020-0001-4000-8000-000000000020", + ["modules:training"] = "f0010021-0001-4000-8000-000000000021", + ["modules:alerts"] = "f0010022-0001-4000-8000-000000000022", + ["modules:tasks"] = "f0010023-0001-4000-8000-000000000023", + ["modules:sensors"] = "f0010024-0001-4000-8000-000000000024", + ["modules:food-waste"] = "f0010025-0001-4000-8000-000000000025", + ["modules:e-label"] = "f0010026-0001-4000-8000-000000000026", + ["management"] = "f0010030-0001-4000-8000-000000000030", + ["management:account"] = "f0010031-0001-4000-8000-000000000031", + ["management:system-menu"] = "f0010032-0001-4000-8000-000000000032", + ["management:menu"] = "f0010033-0001-4000-8000-000000000033", + ["management:devices"] = "f0010034-0001-4000-8000-000000000034", + ["management:reports"] = "f0010035-0001-4000-8000-000000000035", + ["management:invoices"] = "f0010036-0001-4000-8000-000000000036", + ["management:qr-codes"] = "f0010037-0001-4000-8000-000000000037", + ["management:support"] = "f0010038-0001-4000-8000-000000000038", + ["management:api"] = "f0010039-0001-4000-8000-000000000039", + }; /// - /// 全部合法 permission key(含父节点) + /// 是否为仅平台端菜单(不可分配给公司) /// - public static IReadOnlySet AllKeys => AllKeysLazy.Value; + public static bool IsPlatformOnlyMenu(MenuAggregateRoot menu) + { + if (menu == null) + { + return true; + } + + var code = menu.PermissionCode?.Trim() ?? string.Empty; + if (code.StartsWith("menu.platform", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var router = menu.Router?.Trim() ?? string.Empty; + return router.StartsWith("/platform", StringComparison.OrdinalIgnoreCase); + } /// - /// 校验 key 是否合法;返回非法 key 列表 + /// 将历史 SaaS Key / PermissionCode / 菜单 Id 统一为菜单 Id 字符串 /// - public static List FindInvalidKeys(IEnumerable? keys) + public static List NormalizeToMenuIds( + IEnumerable? keys, + IReadOnlyDictionary? permissionCodeToMenuId = null) { if (keys == null) { return new List(); } - return keys - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Select(x => x.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Where(x => !AllKeys.Contains(x)) + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var raw in keys) + { + if (string.IsNullOrWhiteSpace(raw)) + { + continue; + } + + var key = raw.Trim(); + string? menuId = null; + + if (Guid.TryParse(key, out _)) + { + menuId = key; + } + else if (LegacyKeyToMenuId.TryGetValue(key, out var mapped)) + { + menuId = mapped; + } + else if (permissionCodeToMenuId != null + && permissionCodeToMenuId.TryGetValue(key, out var byCode)) + { + menuId = byCode; + } + + if (string.IsNullOrWhiteSpace(menuId) || !seen.Add(menuId)) + { + continue; + } + + result.Add(menuId); + } + + return result; + } + + public static List BuildTree(IEnumerable menus) + { + var list = menus + .Where(m => m != null && !m.IsDeleted && !IsPlatformOnlyMenu(m)) + .OrderBy(m => m.OrderNum) + .ThenBy(m => m.MenuName) .ToList(); + + var nodes = list.ToDictionary( + m => m.Id.ToString(), + m => new ThSaasMenuPermissionTreeNodeDto + { + Key = m.Id.ToString(), + Title = string.IsNullOrWhiteSpace(m.MenuName) ? m.Id.ToString() : m.MenuName!, + Children = new List() + }, + StringComparer.OrdinalIgnoreCase); + + var roots = new List(); + foreach (var menu in list) + { + var node = nodes[menu.Id.ToString()]; + var parentId = string.IsNullOrWhiteSpace(menu.ParentId) ? "0" : menu.ParentId.Trim(); + if (parentId == "0" + || parentId == Guid.Empty.ToString() + || !nodes.TryGetValue(parentId, out var parent)) + { + roots.Add(node); + continue; + } + + parent.Children ??= new List(); + parent.Children.Add(node); + } + + NormalizeEmptyChildren(roots); + return roots; + } + + public static HashSet CollectAssignableMenuIds(IEnumerable menus) + { + return menus + .Where(m => m != null && !m.IsDeleted && !IsPlatformOnlyMenu(m)) + .Select(m => m.Id.ToString()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); } - private static IReadOnlyList BuildTree() => - new List + /// + /// 仅保留公司已开通菜单及其祖先节点(用于租户内角色/用户可分配菜单树)。 + /// + public static List FilterMenusWithAncestors( + IEnumerable allMenus, + IReadOnlySet enabledMenuIds) + { + if (enabledMenuIds.Count == 0) { - Node("dashboard", "仪表盘", Node("dashboard:analytics", "数据分析")), - Node( - "labeling", - "标签管理", - Node("labeling:labels", "标签列表"), - Node("labeling:categories", "标签分类"), - Node("labeling:types", "标签类型"), - Node("labeling:templates", "标签模板"), - Node("labeling:multiple-options", "多选项")), - Node( - "modules", - "功能模块", - Node("modules:training", "培训"), - Node("modules:alerts", "告警"), - Node("modules:tasks", "任务"), - Node("modules:food-waste", "食物浪费"), - Node("modules:e-label", "电子标签")), - Node( - "management", - "系统管理", - Node("management:account", "账号管理"), - Node("management:menu", "菜单管理"), - Node("management:devices", "设备管理"), - Node("management:reports", "报表"), - Node("management:invoices", "发票"), - Node("management:qr-codes", "二维码"), - Node("management:support", "支持"), - Node("management:api", "API")) - }; + return new List(); + } + + var list = allMenus + .Where(m => m != null && !m.IsDeleted && !IsPlatformOnlyMenu(m)) + .ToList(); + var byId = list.ToDictionary(m => m.Id.ToString(), StringComparer.OrdinalIgnoreCase); + var keepIds = new HashSet(StringComparer.OrdinalIgnoreCase); - private static ThSaasMenuPermissionTreeNodeDto Node( - string key, - string title, - params ThSaasMenuPermissionTreeNodeDto[] children) + foreach (var menuId in enabledMenuIds) + { + var currentId = menuId?.Trim() ?? string.Empty; + while (!string.IsNullOrWhiteSpace(currentId) && byId.TryGetValue(currentId, out var menu)) + { + if (!keepIds.Add(currentId)) + { + break; + } + + var parentId = string.IsNullOrWhiteSpace(menu.ParentId) ? "0" : menu.ParentId.Trim(); + if (parentId == "0" || parentId == Guid.Empty.ToString()) + { + break; + } + + currentId = parentId; + } + } + + return list.Where(m => keepIds.Contains(m.Id.ToString())).ToList(); + } + + public static List FindInvalidMenuIds( + IEnumerable? keys, + IReadOnlySet assignableMenuIds) { - return new ThSaasMenuPermissionTreeNodeDto + if (keys == null) { - Key = key, - Title = title, - Children = children.Length == 0 ? null : children.ToList() - }; + return new List(); + } + + return keys + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(x => !assignableMenuIds.Contains(x)) + .ToList(); } - private static IEnumerable CollectAllKeys(IEnumerable nodes) + private static void NormalizeEmptyChildren(List nodes) { foreach (var node in nodes) { - yield return node.Key; - if (node.Children == null) + if (node.Children == null || node.Children.Count == 0) { + node.Children = null; continue; } - foreach (var childKey in CollectAllKeys(node.Children)) - { - yield return childKey; - } + NormalizeEmptyChildren(node.Children); } } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantDatabaseBackgroundInitializer.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantDatabaseBackgroundInitializer.cs index 469d126..7d258cf 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantDatabaseBackgroundInitializer.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantDatabaseBackgroundInitializer.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using FoodLabeling.Th.Application.Contracts.IServices; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Volo.Abp.DependencyInjection; @@ -8,7 +9,7 @@ using Yi.Framework.TenantManagement.Application.Contracts; namespace FoodLabeling.Th.Application.MultiTenancy; /// -/// 使用独立 DI Scope 在后台执行 ,避免阻塞 HTTP 请求。 +/// 使用独立 DI Scope 在后台执行 ,并同步管理员登录邮箱。 /// public class ThTenantDatabaseBackgroundInitializer : IThTenantDatabaseBackgroundInitializer, ISingletonDependency @@ -37,19 +38,47 @@ public class ThTenantDatabaseBackgroundInitializer _ = Task.Run(() => RunInitAsync(tenantId)); } + /// + public bool IsRunning(Guid tenantId) => Running.ContainsKey(tenantId); + private async Task RunInitAsync(Guid tenantId) { try { _logger.LogInformation("租户 {TenantId} 业务库后台初始化开始", tenantId); - using var scope = _scopeFactory.CreateScope(); - var uowManager = scope.ServiceProvider.GetRequiredService(); - var tenantService = scope.ServiceProvider.GetRequiredService(); + // Init 与管理员同步分 Scope,避免同一 UoW 内 SqlSugar 连接缓存导致主库凭据读不到 + using (var initScope = _scopeFactory.CreateScope()) + { + var uowManager = initScope.ServiceProvider.GetRequiredService(); + var tenantService = initScope.ServiceProvider.GetRequiredService(); + + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + try + { + await tenantService.InitAsync(tenantId); + await uow.CompleteAsync(); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "租户 {TenantId} 业务库 InitAsync 失败,仍将尝试同步管理员登录邮箱", + tenantId); + } + } + + using (var syncScope = _scopeFactory.CreateScope()) + { + var adminBootstrapper = syncScope.ServiceProvider + .GetRequiredService(); + await adminBootstrapper.ApplyLoginEmailAsync(tenantId); + await adminBootstrapper.EnsureTenantAdminRoleAsync(tenantId); - using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); - await tenantService.InitAsync(tenantId); - await uow.CompleteAsync(); + var menuSync = syncScope.ServiceProvider + .GetRequiredService(); + await menuSync.EnsureAdminRoleMenusSyncedAsync(tenantId); + } _logger.LogInformation("租户 {TenantId} 业务库后台初始化完成", tenantId); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantHostDataAccessor.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantHostDataAccessor.cs new file mode 100644 index 0000000..766db3a --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantHostDataAccessor.cs @@ -0,0 +1,245 @@ +using FoodLabeling.Th.Domain.Entities; +using SqlSugar; +using Volo.Abp; +using Yi.Framework.Rbac.Domain.Entities; +using Yi.Framework.SqlSugarCore.Abstractions; +using Yi.Framework.TenantManagement.Domain; + +namespace FoodLabeling.Th.Application.MultiTenancy; + +/// +/// 主库(DbConnOptions.Url)直连读取 YiTenant、fl_th_tenant_admin_credential, +/// 避免后台 Init 后 UoW 缓存租户库连接导致凭据查不到,以及 FindAsync 与 TenantId 列映射不一致。 +/// +internal static class ThTenantHostDataAccessor +{ + internal static async Task LoadTenantAsync(DbConnOptions dbConnOptions, Guid tenantId) + { + if (string.IsNullOrWhiteSpace(dbConnOptions.Url)) + { + throw new UserFriendlyException("未配置主库连接字符串 DbConnOptions.Url"); + } + + var dbType = dbConnOptions.DbType ?? DbType.MySql; + using var client = CreateHostClient(dbConnOptions.Url, dbType); + var tenant = await client.Queryable().InSingleAsync(tenantId); + return tenant is { IsDeleted: true } ? null : tenant; + } + + internal static async Task LoadCredentialAsync( + DbConnOptions dbConnOptions, + Guid tenantId) + { + if (string.IsNullOrWhiteSpace(dbConnOptions.Url)) + { + throw new UserFriendlyException("未配置主库连接字符串 DbConnOptions.Url"); + } + + var dbType = dbConnOptions.DbType ?? DbType.MySql; + using var client = CreateHostClient(dbConnOptions.Url, dbType); + var credentials = await client.Queryable() + .Where(c => c.Id == tenantId) + .Take(1) + .ToListAsync(); + return credentials.FirstOrDefault(); + } + + /// + /// 按开通邮箱反查未删除租户 Id(邮箱全局唯一时返回唯一租户;同邮箱多条取最近修改)。 + /// + internal static async Task FindActiveTenantIdByLoginAccountAsync( + DbConnOptions dbConnOptions, + string loginAccount) + { + if (string.IsNullOrWhiteSpace(loginAccount) || string.IsNullOrWhiteSpace(dbConnOptions.Url)) + { + return null; + } + + var normalized = loginAccount.Trim().ToLowerInvariant(); + var dbType = dbConnOptions.DbType ?? DbType.MySql; + using var client = CreateHostClient(dbConnOptions.Url, dbType); + + // YiTenant.Id 与 fl_th_tenant_admin_credential 列 collation 不一致,ORM Join 会报错 + const string sql = """ + SELECT CAST(c.`TenantId` AS CHAR(36)) AS TenantId + FROM `fl_th_tenant_admin_credential` c + INNER JOIN `YiTenant` t + ON CONVERT(CAST(c.`TenantId` AS CHAR(36)) USING utf8mb4) COLLATE utf8mb4_general_ci + = CONVERT(CAST(t.`Id` AS CHAR(36)) USING utf8mb4) COLLATE utf8mb4_general_ci + WHERE t.`IsDeleted` = 0 + AND c.`LoginAccount` IS NOT NULL + AND LOWER(CONVERT(c.`LoginAccount` USING utf8mb4) COLLATE utf8mb4_general_ci) = @LoginAccount + ORDER BY c.`LastModificationTime` DESC + LIMIT 1 + """; + + // SqlSugar SqlQueryAsync 对单列映射不稳定,使用 DTO + var rows = await client.Ado.SqlQueryAsync(sql, new { LoginAccount = normalized }); + var raw = rows.FirstOrDefault()?.TenantId; + if (string.IsNullOrWhiteSpace(raw) || !Guid.TryParse(raw.Trim(), out var tenantId)) + { + return null; + } + + return tenantId; + } + + /// + /// 按租户业务库 user 表 Email/UserName 反查公司租户(Team Member 等非管理员账号登录)。 + /// + internal static async Task FindActiveTenantIdByBusinessUserLoginAsync( + DbConnOptions dbConnOptions, + string loginAccount) + { + if (string.IsNullOrWhiteSpace(loginAccount) || string.IsNullOrWhiteSpace(dbConnOptions.Url)) + { + return null; + } + + var normalized = loginAccount.Trim().ToLowerInvariant(); + var hostDbType = dbConnOptions.DbType ?? DbType.MySql; + using var hostClient = CreateHostClient(dbConnOptions.Url, hostDbType); + + var tenants = await hostClient.Queryable() + .Where(t => !t.IsDeleted) + .Where(t => t.TenantConnectionString != null && t.TenantConnectionString != string.Empty) + .Select(t => new { t.Id, t.TenantConnectionString, t.DbType }) + .ToListAsync(); + + foreach (var tenant in tenants) + { + var connectionString = tenant.TenantConnectionString?.Trim(); + if (string.IsNullOrWhiteSpace(connectionString)) + { + continue; + } + + try + { + var tenantDbType = tenant.DbType == default ? hostDbType : tenant.DbType; + using var tenantClient = TenantBusinessDatabaseAccessor.CreateClient(connectionString, tenantDbType); + tenantClient.Ado.CommandTimeOut = 8; + + const string sql = """ + SELECT CAST(`Id` AS CHAR(36)) AS UserId + FROM `user` + WHERE `IsDeleted` = 0 + AND `State` = 1 + AND ( + (`Email` IS NOT NULL AND LOWER(TRIM(`Email`)) = @LoginAccount) + OR LOWER(TRIM(`UserName`)) = @LoginAccount + ) + LIMIT 1 + """; + + var rows = await tenantClient.Ado.SqlQueryAsync(sql, new { LoginAccount = normalized }); + if (rows.Count > 0 && !string.IsNullOrWhiteSpace(rows[0].UserId)) + { + return tenant.Id; + } + } + catch + { + // 单租户库不可达时跳过,继续扫描其它租户 + } + } + + return null; + } + + internal static async Task> LoadMenuPermissionKeysAsync( + DbConnOptions dbConnOptions, + Guid tenantId) + { + if (string.IsNullOrWhiteSpace(dbConnOptions.Url)) + { + throw new UserFriendlyException("未配置主库连接字符串 DbConnOptions.Url"); + } + + var dbType = dbConnOptions.DbType ?? DbType.MySql; + using var client = CreateHostClient(dbConnOptions.Url, dbType); + return await client.Queryable() + .Where(x => x.TenantId == tenantId) + .OrderBy(x => x.CreationTime) + .Select(x => x.PermissionKey) + .ToListAsync(); + } + + internal static async Task> LoadHostMenusAsync(DbConnOptions dbConnOptions) + { + if (string.IsNullOrWhiteSpace(dbConnOptions.Url)) + { + throw new UserFriendlyException("未配置主库连接字符串 DbConnOptions.Url"); + } + + var dbType = dbConnOptions.DbType ?? DbType.MySql; + using var client = CreateHostClient(dbConnOptions.Url, dbType); + return await client.Queryable() + .Where(x => !x.IsDeleted) + .OrderBy(x => x.OrderNum) + .ToListAsync(); + } + + internal static async Task UpdateTenantAsync(DbConnOptions dbConnOptions, TenantAggregateRoot tenant) + { + if (string.IsNullOrWhiteSpace(dbConnOptions.Url)) + { + throw new UserFriendlyException("未配置主库连接字符串 DbConnOptions.Url"); + } + + var dbType = dbConnOptions.DbType ?? DbType.MySql; + using var client = CreateHostClient(dbConnOptions.Url, dbType); + await client.Updateable(tenant).ExecuteCommandAsync(); + } + + private static SqlSugarClient CreateHostClient(string connectionString, DbType dbType) + { + return new SqlSugarClient(new ConnectionConfig + { + ConfigId = $"th-host-read-{Guid.NewGuid():N}", + DbType = dbType, + ConnectionString = connectionString, + IsAutoCloseConnection = true, + MoreSettings = new ConnMoreSettings + { + // 避免反查登录租户时长时间卡在不可达库 + IsAutoRemoveDataCache = true + }, + AopEvents = new AopEvents + { + OnError = _ => { } + }, + // 与 DefaultSqlSugarDbContext 一致:主库 menu 等表无 ExtraProperties 列 + ConfigureExternalServices = new ConfigureExternalServices + { + EntityService = (propertyInfo, columnInfo) => + { + if (propertyInfo.PropertyType == typeof(Volo.Abp.Data.ExtraPropertyDictionary)) + { + columnInfo.IsIgnore = true; + } + + if (propertyInfo.Name == nameof(Volo.Abp.Domain.Entities.Entity.Id)) + { + columnInfo.IsPrimarykey = true; + } + } + } + }, + db => + { + db.Ado.CommandTimeOut = 8; + }); + } + + private sealed class TenantIdSqlRow + { + public string? TenantId { get; set; } + } + + private sealed class UserIdSqlRow + { + public string? UserId { get; set; } + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantSelectConsts.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantSelectConsts.cs index 94c8d42..9515059 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantSelectConsts.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantSelectConsts.cs @@ -5,6 +5,9 @@ namespace FoodLabeling.Th.Application.MultiTenancy; /// public static class ThTenantSelectConsts { - /// 种子管理员 UserName,与 UserDataSeed 一致 + /// 业务租户 Seed 管理员 UserName,与 UserDataSeed 一致 public const string DefaultAdminLoginAccount = "admin"; + + /// 平台 Default 租户展示/登录邮箱(主库 User.Email) + public const string PlatformAdminLoginAccount = "admin@example.com"; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThWebPlatformLoginHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThWebPlatformLoginHelper.cs index 8bd922a..4ec3352 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThWebPlatformLoginHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThWebPlatformLoginHelper.cs @@ -14,6 +14,16 @@ public static class ThWebPlatformLoginHelper /// 平台登录成功时 th-web-auth 返回的 tenantName(无 TenantId Claim) public const string PlatformTenantDisplayName = "Platform"; + /// 平台邮箱账号误选业务租户时的登录拒绝提示 + public const string PlatformAccountMustUseDefaultMessage = + "登录失败:平台管理员邮箱请选择 Default 选项登录,不能使用业务租户登录"; + + /// + /// 是否为业务租户登录(选了具体公司,而非 Default / 空 tenantId) + /// + public static bool IsBusinessTenantLogin(Guid? tenantId, string? tenantName) + => !ShouldTryPlatformLogin(tenantId, tenantName); + /// /// 是否应优先尝试主库平台登录(与前端选 Default / 空 tenantId 一致) /// diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThAppAuthAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThAppAuthAppService.cs index 8fbb8c9..f33e2f0 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThAppAuthAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThAppAuthAppService.cs @@ -11,6 +11,7 @@ using FoodLabeling.Th.Application.Contracts.IServices; using Lazy.Captcha.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using SqlSugar; @@ -28,68 +29,106 @@ using Yi.Framework.SqlSugarCore.Abstractions; namespace FoodLabeling.Th.Application.Services; /// -/// 泰额版 App 登录:先解析租户,再在租户独立库校验用户,JWT 写入 TenantId +/// 泰额版 App 登录:解析租户后在租户独立库校验用户,JWT 写入 TenantId /// public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService { private readonly IAccountManager _accountManager; private readonly ISqlSugarRepository _userRepository; private readonly ISugarDbContextProvider _dbContextProvider; + private readonly TenantAdminAccountBootstrapper _adminAccountBootstrapper; private readonly ICaptcha _captcha; private readonly RbacOptions _rbacOptions; private readonly JwtOptions _jwtOptions; private readonly ITenantStore _tenantStore; + private readonly DbConnOptions _dbConnOptions; public ThAppAuthAppService( IAccountManager accountManager, ISqlSugarRepository userRepository, ISugarDbContextProvider dbContextProvider, + TenantAdminAccountBootstrapper adminAccountBootstrapper, ICaptcha captcha, IOptions jwtOptions, IOptions rbacOptions, - ITenantStore tenantStore) + ITenantStore tenantStore, + IOptions dbConnOptions) { _accountManager = accountManager; _userRepository = userRepository; _dbContextProvider = dbContextProvider; + _adminAccountBootstrapper = adminAccountBootstrapper; _captcha = captcha; _jwtOptions = jwtOptions.Value; _rbacOptions = rbacOptions.Value; _tenantStore = tenantStore; + _dbConnOptions = dbConnOptions.Value; } - /// + /// + /// App 登录:校验租户后在租户业务库验证账号并签发 Token(Claim 含 TenantId) + /// + /// + /// 可空:与 th-web-auth/login 一致,按邮箱/用户名反查公司租户 + /// (开通邮箱 fl_th_tenant_admin_credential 或各租户业务库 user 表)。 + /// + /// 示例(可不传 tenantId): + /// ```json + /// { "email": "mai@123.com", "password": "123456" } + /// ``` + /// + /// 登录参数 + /// Token、RefreshToken、租户信息与绑定门店 + /// 登录成功 + /// 参数缺失、验证码错误或账号密码错误 + /// 服务器错误 [AllowAnonymous] [HttpPost("th-app-auth/login")] public virtual async Task LoginAsync(ThAppLoginInputVo input) { - if (input.TenantId == Guid.Empty - || string.IsNullOrWhiteSpace(input.Password) + if (string.IsNullOrWhiteSpace(input.Password) || string.IsNullOrWhiteSpace(input.Email)) { - throw new UserFriendlyException("请输入租户、邮箱与密码!"); + throw new UserFriendlyException("请输入邮箱与密码!"); } ValidationImageCaptcha(input.Uuid, input.Code); - var tenantConfig = await TenantResolveHelper.ResolveTenantAsync( - _tenantStore, CurrentTenant, input.TenantId); + var tenantId = await ResolveTenantIdForLoginAsync(input); + if (!tenantId.HasValue || tenantId.Value == Guid.Empty) + { + throw new UserFriendlyException("登录失败:未找到该邮箱对应的公司,请确认邮箱或联系管理员"); + } - UserAggregateRoot user; - List locations; - using (CurrentTenant.Change(input.TenantId, tenantConfig.Name)) + if (ThWebPlatformLoginHelper.IsBusinessTenantLogin(tenantId, null)) { - user = await FindActiveUserByEmailAsync(input.Email.Trim()) + await EnsureNotHostPlatformAccountAsync(input.Email); + } + + var tenantConfig = await TenantResolveHelper.ResolveTenantAsync( + _tenantStore, CurrentTenant, tenantId.Value); + + // 与 th-web-auth 对齐:开通邮箱已在主库凭据中、但租户库仍为 Seed admin 时,登录前补同步 + await EnsureTenantAdminLoginSyncedAsync(tenantId.Value, input.Email.Trim()); + + // 优先直连租户库查用户,避免 CurrentTenant.Change 后仓储仍连主库导致「邮箱不存在」 + var user = await FindActiveUserByEmailInTenantDbAsync(tenantId.Value, input.Email.Trim()) ?? throw new UserFriendlyException("登录失败!邮箱不存在!"); - if (!UserPasswordHelper.VerifyPlainPassword(user, input.Password)) - { - throw new UserFriendlyException(UserConst.Login_Error); - } + if (!UserPasswordHelper.VerifyPlainPassword(user, input.Password) + && !user.JudgePassword(input.Password)) + { + throw new UserFriendlyException(UserConst.Login_Error); + } + List locations; + List roleCodes; + using (CurrentTenant.Change(tenantId.Value, tenantConfig.Name)) + { locations = await LoadBoundLocationsAsync(user.Id); + roleCodes = await LoadUserRoleCodesAsync(user.Id); } - var accessToken = CreateAppAccessToken(user, input.TenantId); + var accessToken = CreateAppAccessToken(user, tenantId.Value, roleCodes); var refreshToken = _accountManager.CreateRefreshToken(user.Id); // 不发布 LoginEvent:事件在 UoW 完成时于「当前连接」写 LoginLog;泰额 App 校验在租户库, @@ -99,7 +138,7 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService { Token = accessToken, RefreshToken = refreshToken, - TenantId = input.TenantId, + TenantId = tenantId.Value, TenantName = tenantConfig.Name ?? string.Empty, Locations = locations }; @@ -126,6 +165,95 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService } } + /// + /// 解析登录租户:显式 tenantId 优先;否则按邮箱反查(与 th-web-auth 对齐)。 + /// + private async Task ResolveTenantIdForLoginAsync(ThAppLoginInputVo input) + { + if (input.TenantId.HasValue && input.TenantId.Value != Guid.Empty) + { + if (input.TenantId.Value == ThWebPlatformLoginHelper.DefaultTenantId) + { + return await ThLoginTenantResolver.TryResolveCompanyTenantIdByLoginAsync( + _dbConnOptions, + input.Email, + Logger); + } + + return input.TenantId.Value; + } + + return await ThLoginTenantResolver.TryResolveCompanyTenantIdByLoginAsync( + _dbConnOptions, + input.Email, + Logger); + } + + /// + /// 业务租户登录前校验:主库平台邮箱账号不得落入公司业务库。 + /// + private async Task EnsureNotHostPlatformAccountAsync(string email) + { + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(email)) + { + return; + } + + using (CurrentTenant.Change(null)) + { + var hostUser = await FindActiveUserByEmailAsync(email.Trim()); + if (hostUser != null) + { + throw new UserFriendlyException(ThWebPlatformLoginHelper.PlatformAccountMustUseDefaultMessage); + } + } + } + + /// + /// 开通邮箱已写入主库凭据、但业务库仍为 Seed 的 admin 时,登录前补同步一次(与 th-web-auth 一致)。 + /// + private async Task EnsureTenantAdminLoginSyncedAsync(Guid tenantId, string loginAccount) + { + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(loginAccount)) + { + return; + } + + var credentialAccount = await _adminAccountBootstrapper.GetProvisionedLoginAccountAsync(tenantId); + if (string.IsNullOrWhiteSpace(credentialAccount) + || !string.Equals(credentialAccount, loginAccount.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + using (CurrentTenant.Change(tenantId)) + { + var existing = await FindActiveUserByEmailAsync(loginAccount); + if (existing != null) + { + await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId); + return; + } + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "租户 {TenantId} App 登录前探测用户失败,尝试同步开通邮箱", tenantId); + } + + try + { + await _adminAccountBootstrapper.ApplyLoginEmailAsync(tenantId); + await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "租户 {TenantId} App 登录前同步管理员邮箱失败", tenantId); + } + } + private void ValidationImageCaptcha(string? uuid, string? code) { if (!_rbacOptions.EnableCaptcha) @@ -142,7 +270,9 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService private async Task FindActiveUserByEmailAsync(string email) { var normalized = email.Trim().ToLowerInvariant(); - var users = await _userRepository._DbQueryable + // Change 租户后必须用 Provider 取当前库连接;仓储可能仍缓存主库连接 + var db = (await _dbContextProvider.GetDbContextAsync()).SqlSugarClient; + var users = await db.Queryable() .Where(u => !u.IsDeleted && u.State) .Where(u => (u.Email != null && SqlFunc.ToLower(u.Email) == normalized) || @@ -154,7 +284,39 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService ?? users.FirstOrDefault(); } - private string CreateAppAccessToken(UserAggregateRoot user, Guid tenantId) + /// + /// 直连租户业务库查用户(绕过 CurrentTenant 连接切换不稳定问题)。 + /// + private async Task FindActiveUserByEmailInTenantDbAsync(Guid tenantId, string email) + { + var tenant = await ThTenantHostDataAccessor.LoadTenantAsync(_dbConnOptions, tenantId); + if (tenant is null || string.IsNullOrWhiteSpace(tenant.TenantConnectionString)) + { + return null; + } + + var dbType = tenant.DbType == default + ? (_dbConnOptions.DbType ?? SqlSugar.DbType.MySql) + : tenant.DbType; + using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient( + tenant.TenantConnectionString.Trim(), + dbType); + tenantDb.Ado.CommandTimeOut = 15; + + var normalized = email.Trim().ToLowerInvariant(); + var users = await tenantDb.Queryable() + .Where(u => !u.IsDeleted && u.State) + .Where(u => + (u.Email != null && SqlFunc.ToLower(u.Email) == normalized) || + SqlFunc.ToLower(u.UserName) == normalized) + .ToListAsync(); + return users.FirstOrDefault(u => + u.Email != null && + string.Equals(u.Email.Trim(), normalized, StringComparison.OrdinalIgnoreCase)) + ?? users.FirstOrDefault(); + } + + private string CreateAppAccessToken(UserAggregateRoot user, Guid tenantId, IReadOnlyList roleCodes) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecurityKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); @@ -174,6 +336,21 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService claims.Add(new Claim(AbpClaimTypes.Email, user.Email)); } + var isAdmin = roleCodes.Any(r => + string.Equals(r, UserConst.AdminRolesCode, StringComparison.OrdinalIgnoreCase)); + if (isAdmin || string.Equals(user.UserName, UserConst.Admin, StringComparison.OrdinalIgnoreCase)) + { + claims.Add(new Claim(TokenTypeConst.Permission, UserConst.AdminPermissionCode)); + claims.Add(new Claim(TokenTypeConst.Roles, UserConst.AdminRolesCode)); + } + else + { + foreach (var role in roleCodes.Where(r => !string.IsNullOrWhiteSpace(r)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + claims.Add(new Claim(AbpClaimTypes.Role, role)); + } + } + var token = new JwtSecurityToken( _jwtOptions.Issuer, _jwtOptions.Audience, @@ -185,6 +362,29 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService return new JwtSecurityTokenHandler().WriteToken(token); } + private async Task> LoadUserRoleCodesAsync(Guid userId) + { + var db = (await _dbContextProvider.GetDbContextAsync()).SqlSugarClient; + var rows = await db.Ado.SqlQueryAsync( + """ + SELECT r.RoleCode + FROM UserRole ur + INNER JOIN Role r ON ur.RoleId = r.Id + WHERE ur.UserId = @UserId AND r.IsDeleted = 0 AND r.State = 1 + """, + new { UserId = userId }); + return rows + .Select(r => r.RoleCode?.Trim()) + .Where(r => !string.IsNullOrWhiteSpace(r)) + .Cast() + .ToList(); + } + + private sealed class RoleCodeRow + { + public string? RoleCode { get; set; } + } + private async Task> LoadBoundLocationsAsync(Guid userId) { var db = (await _dbContextProvider.GetDbContextAsync()).SqlSugarClient; diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThAuthSessionAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThAuthSessionAppService.cs new file mode 100644 index 0000000..abb6129 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThAuthSessionAppService.cs @@ -0,0 +1,61 @@ +using FoodLabeling.Application.Contracts.Dtos.AuthSession; +using FoodLabeling.Application.Contracts.IServices; +using FoodLabeling.Application.Helpers; +using FoodLabeling.Application.Services; +using FoodLabeling.Th.Application.Contracts.IServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Volo.Abp.Caching; +using Volo.Abp.DependencyInjection; +using Yi.Framework.Rbac.Domain.Entities; +using Yi.Framework.Rbac.Domain.Shared.Caches; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Application.Services; + +/// +/// 泰额版:my-menus 前幂等同步主库公司菜单开通到租户 admin 角色 RoleMenu。 +/// +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IAuthSessionAppService), typeof(AuthSessionAppService))] +public class ThAuthSessionAppService : AuthSessionAppService +{ + private readonly ITenantCompanyMenuSyncService _companyMenuSyncService; + private readonly DbConnOptions _dbConnOptions; + + public ThAuthSessionAppService( + ISqlSugarDbContext dbContext, + ISqlSugarRepository userRepository, + IDistributedCache userCache, + IDistributedCache systemEditStampCache, + IOptions dbConnOptions, + ITenantCompanyMenuSyncService companyMenuSyncService) + : base(dbContext, userRepository, userCache, systemEditStampCache, dbConnOptions) + { + _companyMenuSyncService = companyMenuSyncService; + _dbConnOptions = dbConnOptions.Value; + } + + /// + public override async Task GetMyMenusAsync() + { + if (_dbConnOptions.EnabledSaasMultiTenancy + && CurrentTenant.Id.HasValue + && CurrentTenant.Id.Value != Guid.Empty) + { + try + { + await _companyMenuSyncService.EnsureAdminRoleMenusSyncedAsync(CurrentTenant.Id.Value); + } + catch (Exception ex) + { + Logger.LogWarning( + ex, + "租户 {TenantId} my-menus 前同步公司菜单失败,将按现有 RoleMenu 返回", + CurrentTenant.Id); + } + } + + return await base.GetMyMenusAsync(); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs index 549da0a..bd7115b 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using FoodLabeling.Application.Helpers; using FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; using FoodLabeling.Th.Application.Contracts.IServices; using FoodLabeling.Th.Application.Contracts.Options; @@ -6,6 +7,7 @@ using FoodLabeling.Th.Application.MultiTenancy; using FoodLabeling.Th.Domain.Entities; using FoodLabeling.Th.Domain.Shared.Helpers; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using SqlSugar; @@ -46,32 +48,38 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe private readonly ISqlSugarRepository _tenantRepository; private readonly ISqlSugarRepository _credentialRepository; private readonly ISqlSugarRepository _menuPermissionRepository; + private readonly ISqlSugarRepository _menuRepository; private readonly TenantSelectCredentialCipher _credentialCipher; private readonly RbacOptions _rbacOptions; private readonly FoodLabelingThTenantDatabaseOptions _tenantDatabaseOptions; private readonly DbConnOptions _dbConnOptions; private readonly IDistributedCache _tenantCache; + private readonly ITenantCompanyMenuSyncService _companyMenuSyncService; public ThMultiTenancyAppService( ITenantService tenantService, ISqlSugarRepository tenantRepository, ISqlSugarRepository credentialRepository, ISqlSugarRepository menuPermissionRepository, + ISqlSugarRepository menuRepository, TenantSelectCredentialCipher credentialCipher, IOptions rbacOptions, IOptions tenantDatabaseOptions, IOptions dbConnOptions, - IDistributedCache tenantCache) + IDistributedCache tenantCache, + ITenantCompanyMenuSyncService companyMenuSyncService) { _tenantService = tenantService; _tenantRepository = tenantRepository; _credentialRepository = credentialRepository; _menuPermissionRepository = menuPermissionRepository; + _menuRepository = menuRepository; _credentialCipher = credentialCipher; _rbacOptions = rbacOptions.Value; _tenantDatabaseOptions = tenantDatabaseOptions.Value; _dbConnOptions = dbConnOptions.Value; _tenantCache = tenantCache; + _companyMenuSyncService = companyMenuSyncService; } /// @@ -103,7 +111,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe /// - id: 租户 Id,登录 th-web-auth/login 时传 tenantId;选 Default 且用平台邮箱则走主库平台登录 /// - name: 租户名称,登录时可传 tenantName /// - creationTime: 租户创建时间 - /// - loginAccount: 管理员登录账号,优先读凭据表,否则为 admin + /// - loginAccount: 管理员登录账号;优先读凭据表;Default 平台默认为 admin@example.com,业务租户默认为 admin /// - password: 优先读凭据表 AES 密文,否则 RbacOptions.AdminPassword 加密后返回 /// - passwordSalt: AES IV(Base64),前端解密时使用 /// @@ -125,7 +133,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe .Select(x => { credentialMap.TryGetValue(x.Id, out var credential); - var resolved = ResolveEncryptedCredential(credential); + var resolved = ResolveEncryptedCredential(x.Id, credential); return new ThTenantSelectDto { Id = x.Id, @@ -202,7 +210,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe var items = page.Items.Select(x => { credentialMap.TryGetValue(x.Id, out var credential); - var resolved = ResolveEncryptedCredential(credential); + var resolved = ResolveEncryptedCredential(x.Id, credential); menuPermissionMap.TryGetValue(x.Id, out var menuKeys); return new ThCompanyListItemDto { @@ -277,8 +285,9 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe existingCredential = credentials.FirstOrDefault(); } - var currentLoginAccount = existingCredential?.LoginAccount?.Trim() - ?? ThTenantSelectConsts.DefaultAdminLoginAccount; + var currentLoginAccount = ResolveDefaultLoginAccount( + input.TenantId, + existingCredential?.LoginAccount); var newLoginAccount = !string.IsNullOrWhiteSpace(input.LoginAccount) ? input.LoginAccount.Trim() : currentLoginAccount; @@ -333,33 +342,78 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe } /// - /// 获取 SaaS 菜单权限树 + /// 获取可分配给公司的平台菜单树(主库 Menu,排除仅平台端菜单) /// /// - /// 供平台管理员配置租户菜单权限时使用;Key 与前端 saas-menu-tree 一致。 - /// - /// 示例响应: - /// ```json - /// [ - /// { - /// "key": "dashboard", - /// "title": "仪表盘", - /// "children": [ - /// { "key": "dashboard:analytics", "title": "数据分析" } - /// ] - /// } - /// ] - /// ``` + /// 平台主库 / Default 租户:返回全部可分配公司菜单。 + /// 公司业务租户上下文:仅返回该公司已开通菜单(fl_th_tenant_menu_permission)及祖先节点。 + /// 节点 key = 菜单 Id(与租户业务库固定 Guid 种子一致)。 /// - /// SaaS 菜单权限树 - /// 成功返回权限树 + [Authorize] + [HttpGet("th-multi-tenancy/menu-permission-tree")] + public virtual async Task> GetMenuPermissionTreeAsync() + { + var menus = await LoadHostMenusAsync(); + var httpContext = LazyServiceProvider.LazyGetService()?.HttpContext; + var tenantId = TenantBusinessContextHelper.ResolveBusinessTenantId(CurrentTenant, httpContext); + if (TenantBusinessContextHelper.ShouldScopeMenusByCompany(_dbConnOptions, tenantId)) + { + var enabledMenuIds = await TenantCompanyMenuScopeHelper.LoadEnabledMenuIdsAsync( + _dbConnOptions, + tenantId!.Value); + menus = ThSaasMenuPermissionCatalog.FilterMenusWithAncestors(menus, enabledMenuIds); + } + + var tree = ThSaasMenuPermissionCatalog.BuildTree(menus); + if (tree.Count == 0 + && TenantBusinessContextHelper.ShouldScopeMenusByCompany(_dbConnOptions, tenantId)) + { + return new List + { + new() + { + Key = "company-menu-empty", + Title = "暂无可用菜单", + Children = null + } + }; + } + + return tree; + } + + /// + /// 获取当前公司业务租户已分配的 SaaS 菜单权限 + /// + /// + /// 需登录;从 JWT / __tenant 解析租户 Id,读取主库 fl_th_tenant_menu_permission。 + /// 供租户内角色编辑等场景获取公司已开通菜单,无需显式传 tenantId。 + /// + /// 当前租户菜单权限 + /// 成功返回菜单权限 + /// 未识别租户上下文 /// 未登录 + /// 租户不存在 /// 服务器错误 [Authorize] - [HttpGet("th-multi-tenancy/menu-permission-tree")] - public virtual Task> GetMenuPermissionTreeAsync() + [HttpGet("th-multi-tenancy/my-company-menus")] + public virtual async Task GetMyCompanyMenusAsync() { - return Task.FromResult(CloneTree(ThSaasMenuPermissionCatalog.Tree)); + var httpContext = LazyServiceProvider.LazyGetService()?.HttpContext; + var tenantId = TenantBusinessContextHelper.ResolveBusinessTenantId(CurrentTenant, httpContext); + if (!tenantId.HasValue) + { + throw new UserFriendlyException( + "未识别租户上下文。请使用泰额登录接口选择具体公司登录,或请求头 __tenant 携带租户 Id。"); + } + + await EnsureTenantExistsAsync(tenantId.Value); + var keys = await LoadMenuPermissionKeysAsync(tenantId.Value); + return new ThCompanyMenusDto + { + TenantId = tenantId.Value, + MenuPermissionKeys = keys + }; } /// @@ -420,11 +474,11 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe /// /// 参数说明: /// - tenantId: 租户 Id(必填) - /// - menuPermissionKeys: 菜单权限 Key 列表(覆盖式,可为空表示清空) + /// - menuPermissionKeys: 菜单权限 Key 列表(覆盖式;传 [] 或 null 表示清空该公司全部开通菜单) /// /// 租户 Id 与菜单权限 Key 列表 /// 无内容 - /// 设置成功 + /// 设置成功(含清空) /// 参数错误或存在非法 permission key /// 未登录 /// 租户不存在 @@ -441,8 +495,20 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe await EnsureTenantExistsAsync(input.TenantId); - var normalizedKeys = NormalizePermissionKeys(input.MenuPermissionKeys); - var invalidKeys = ThSaasMenuPermissionCatalog.FindInvalidKeys(normalizedKeys); + // 允许 null / []:覆盖式清空该公司开通菜单 + var rawKeys = input.MenuPermissionKeys ?? new List(); + + var hostMenus = await LoadHostMenusAsync(); + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); + var permissionCodeMap = hostMenus + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); + + var normalizedKeys = ThSaasMenuPermissionCatalog.NormalizeToMenuIds( + rawKeys, + permissionCodeMap); + var invalidKeys = ThSaasMenuPermissionCatalog.FindInvalidMenuIds(normalizedKeys, assignableIds); if (invalidKeys.Count > 0) { throw new UserFriendlyException($"存在非法菜单权限 Key:{string.Join(", ", invalidKeys)}"); @@ -452,22 +518,32 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe { await _menuPermissionRepository.DeleteAsync(x => x.TenantId == input.TenantId); - if (normalizedKeys.Count == 0) + if (normalizedKeys.Count > 0) { - return; - } + var now = DateTime.Now; + var entities = normalizedKeys.Select(key => new ThTenantMenuPermissionEntity + { + Id = YitIdHelper.NextId().ToString(), + TenantId = input.TenantId, + PermissionKey = key, + CreationTime = now + }).ToList(); - var now = DateTime.Now; - var entities = normalizedKeys.Select(key => new ThTenantMenuPermissionEntity - { - Id = YitIdHelper.NextId().ToString(), - TenantId = input.TenantId, - PermissionKey = key, - CreationTime = now - }).ToList(); + await _menuPermissionRepository.InsertRangeAsync(entities); + } + } - await _menuPermissionRepository.InsertRangeAsync(entities); + // Default 平台租户只维护主库开通表,禁止同步到其连接串指向的业务库(常为 US 库) + if (input.TenantId == ProtectedDefaultTenantId) + { + return; } + + var menuIds = normalizedKeys + .Select(x => Guid.TryParse(x, out var id) ? id : Guid.Empty) + .Where(x => x != Guid.Empty) + .ToList(); + await _companyMenuSyncService.EnsureAdminRoleMenusSyncedAsync(input.TenantId); } /// @@ -590,6 +666,30 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe var tenant = await EnsureTenantExistsAsync(input.TenantId); var menuIds = ParseMenuIds(input.MenuIds); + + // 角色菜单不得超过平台分配给该公司的菜单范围 + var allowedKeys = await LoadMenuPermissionKeysAsync(input.TenantId); + if (allowedKeys.Count == 0) + { + if (menuIds.Count > 0) + { + throw new UserFriendlyException("该公司尚未开通任何菜单,请先在「菜单权限」中分配"); + } + } + else + { + var allowed = allowedKeys.ToHashSet(StringComparer.OrdinalIgnoreCase); + var outOfScope = menuIds + .Select(x => x.ToString()) + .Where(x => !allowed.Contains(x)) + .ToList(); + if (outOfScope.Count > 0) + { + throw new UserFriendlyException( + $"角色菜单超出公司已开通范围:{string.Join(", ", outOfScope)}"); + } + } + var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant); using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, input.TenantId); @@ -779,25 +879,47 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe } } + /// + /// 解析可回显凭据。Default 平台租户默认登录账号为 。 + /// private (string LoginAccount, string Password, string PasswordSalt) ResolveEncryptedCredential( + Guid tenantId, ThTenantAdminCredentialEntity? credential) { + var fallbackAccount = ResolveDefaultLoginAccount(tenantId, credential?.LoginAccount); + if (credential != null && !string.IsNullOrEmpty(credential.PasswordCipher) && !string.IsNullOrEmpty(credential.PasswordIv)) { - var loginAccount = string.IsNullOrWhiteSpace(credential.LoginAccount) - ? ThTenantSelectConsts.DefaultAdminLoginAccount - : credential.LoginAccount; - return (loginAccount, credential.PasswordCipher, credential.PasswordIv); + return (fallbackAccount, credential.PasswordCipher, credential.PasswordIv); } var plainPassword = _rbacOptions.AdminPassword ?? string.Empty; var encrypted = _credentialCipher.EncryptPassword(plainPassword); - var account = string.IsNullOrWhiteSpace(credential?.LoginAccount) + return (fallbackAccount, encrypted.Password, encrypted.PasswordSalt); + } + + /// + /// Default 平台:空或历史 admin → admin@example.com;业务租户:空 → admin。 + /// + private static string ResolveDefaultLoginAccount(Guid tenantId, string? storedLoginAccount) + { + var stored = storedLoginAccount?.Trim(); + if (tenantId == ProtectedDefaultTenantId) + { + if (string.IsNullOrWhiteSpace(stored) + || string.Equals(stored, ThTenantSelectConsts.DefaultAdminLoginAccount, StringComparison.OrdinalIgnoreCase)) + { + return ThTenantSelectConsts.PlatformAdminLoginAccount; + } + + return stored; + } + + return string.IsNullOrWhiteSpace(stored) ? ThTenantSelectConsts.DefaultAdminLoginAccount - : credential!.LoginAccount; - return (account, encrypted.Password, encrypted.PasswordSalt); + : stored; } private async Task UpsertCredentialAsync( @@ -905,16 +1027,39 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe private async Task> LoadMenuPermissionKeysAsync(Guid tenantId) { + List rawKeys; using (UseHostTenantScope()) { - return await _menuPermissionRepository._DbQueryable + rawKeys = await _menuPermissionRepository._DbQueryable .Where(x => x.TenantId == tenantId) .OrderBy(x => x.CreationTime) .Select(x => x.PermissionKey) .ToListAsync(); } + + var hostMenus = await LoadHostMenusAsync(); + var permissionCodeMap = hostMenus + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); + return ThSaasMenuPermissionCatalog.NormalizeToMenuIds(rawKeys, permissionCodeMap) + .Where(assignableIds.Contains) + .ToList(); } + private async Task> LoadHostMenusAsync() + { + using (UseHostTenantScope()) + { + return await _menuRepository._DbQueryable + .Where(x => !x.IsDeleted) + .OrderBy(x => x.OrderNum) + .ToListAsync(); + } + } + + private async Task>> LoadMenuPermissionMapAsync( IReadOnlyCollection tenantIds) { @@ -923,32 +1068,30 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe return new Dictionary>(); } + List list; using (UseHostTenantScope()) { - var list = await _menuPermissionRepository._DbQueryable + list = await _menuPermissionRepository._DbQueryable .Where(x => tenantIds.Contains(x.TenantId)) .ToListAsync(); - - return list - .GroupBy(x => x.TenantId) - .ToDictionary( - g => g.Key, - g => g.Select(x => x.PermissionKey).Distinct(StringComparer.OrdinalIgnoreCase).ToList()); } - } - private static List NormalizePermissionKeys(IEnumerable? keys) - { - if (keys == null) - { - return new List(); - } + var hostMenus = await LoadHostMenusAsync(); + var permissionCodeMap = hostMenus + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); - return keys - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Select(x => x.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + return list + .GroupBy(x => x.TenantId) + .ToDictionary( + g => g.Key, + g => ThSaasMenuPermissionCatalog.NormalizeToMenuIds( + g.Select(x => x.PermissionKey), + permissionCodeMap) + .Where(assignableIds.Contains) + .ToList()); } private static List ParseMenuIds(IEnumerable? menuIds) @@ -981,17 +1124,6 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe return result; } - private static List CloneTree( - IEnumerable nodes) - { - return nodes.Select(node => new ThSaasMenuPermissionTreeNodeDto - { - Key = node.Key, - Title = node.Title, - Children = node.Children == null ? null : CloneTree(node.Children) - }).ToList(); - } - private static void EnsureTenantDeletable(Guid tenantId, string? tenantName) { if (tenantId == ProtectedDefaultTenantId) diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs index 91833f5..892bd4f 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs @@ -1,9 +1,13 @@ +using FoodLabeling.Application.Helpers; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Th.Application.Contracts.Dtos.RbacMenu; using FoodLabeling.Th.Application.Contracts.IServices; +using FoodLabeling.Th.Application.MultiTenancy; using FoodLabeling.Th.Domain.Shared.Helpers; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Dtos; @@ -20,10 +24,17 @@ namespace FoodLabeling.Th.Application.Services; public class ThRbacMenuAppService : ApplicationService, IThRbacMenuAppService { private readonly ISqlSugarDbContext _dbContext; + private readonly DbConnOptions _dbConnOptions; + private readonly IHttpContextAccessor _httpContextAccessor; - public ThRbacMenuAppService(ISqlSugarDbContext dbContext) + public ThRbacMenuAppService( + ISqlSugarDbContext dbContext, + IOptions dbConnOptions, + IHttpContextAccessor httpContextAccessor) { _dbContext = dbContext; + _dbConnOptions = dbConnOptions.Value; + _httpContextAccessor = httpContextAccessor; } /// @@ -264,8 +275,9 @@ public class ThRbacMenuAppService : ApplicationService, IThRbacMenuAppService /// 获取全部菜单树 /// /// - /// 返回当前租户业务库全部未删除菜单,按 ParentId 字符串组树(不使用 Guid TreeHelper)。 - /// 树节点按 OrderNum 降序排序。 + /// 平台主库 / Default:返回当前库全部未删除菜单。 + /// 公司业务租户:仅返回该公司已开通菜单(fl_th_tenant_menu_permission)及祖先节点。 + /// 按 ParentId 字符串组树;树节点按 OrderNum 降序排序。 /// /// 根节点菜单树 /// 成功返回菜单树 @@ -279,6 +291,17 @@ public class ThRbacMenuAppService : ApplicationService, IThRbacMenuAppService .OrderBy(x => x.OrderNum, OrderByType.Desc) .ToListAsync(); + var tenantId = TenantBusinessContextHelper.ResolveBusinessTenantId( + CurrentTenant, + _httpContextAccessor.HttpContext); + if (TenantBusinessContextHelper.ShouldScopeMenusByCompany(_dbConnOptions, tenantId)) + { + var enabledMenuIds = await TenantCompanyMenuScopeHelper.LoadEnabledMenuIdsAsync( + _dbConnOptions, + tenantId!.Value); + menus = PlatformMenuHelper.FilterCompanyMenusWithAncestors(menus, enabledMenuIds); + } + var nodes = menus.Select(MapToTreeDto).ToList(); return BuildMenuTree(nodes); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs index 6c9fb49..fa027c9 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs @@ -2,14 +2,18 @@ using FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; using FoodLabeling.Th.Application.Contracts.IServices; using FoodLabeling.Th.Application.Contracts.Options; using FoodLabeling.Th.Application.MultiTenancy; +using FoodLabeling.Th.Domain.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Timeouts; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; +using Yi.Framework.Rbac.Domain.Entities; +using Yi.Framework.Rbac.Domain.Shared.Options; using Yi.Framework.SqlSugarCore.Abstractions; using Yi.Framework.TenantManagement.Application.Contracts; using Yi.Framework.TenantManagement.Application.Contracts.Dtos; @@ -19,25 +23,6 @@ namespace FoodLabeling.Th.Application.Services; /// /// 泰额版租户独立库开通(平台主库 yitenant + 租户业务库) /// -/// -/// 脏数据修复(业务库 antis-foodlabeling-us 误写入的 YiTenant 迁回主库 antis-foodlabeling-host,名称冲突则跳过): -/// -/// INSERT INTO `antis-foodlabeling-host`.YiTenant -/// (Id, Name, TenantConnectionString, DbType, EntityVersion, -/// ConcurrencyStamp, CreationTime, CreatorId, -/// LastModificationTime, LastModifierId, IsDeleted, DeleterId, DeletionTime) -/// SELECT u.Id, u.Name, u.TenantConnectionString, u.DbType, u.EntityVersion, -/// u.ConcurrencyStamp, u.CreationTime, u.CreatorId, -/// u.LastModificationTime, u.LastModifierId, u.IsDeleted, u.DeleterId, u.DeletionTime -/// FROM `antis-foodlabeling-us`.YiTenant u -/// WHERE u.Name IN ('mike', '中国麦当劳公司') -/// AND NOT EXISTS ( -/// SELECT 1 FROM `antis-foodlabeling-host`.YiTenant h WHERE h.Name = u.Name OR h.Id = u.Id -/// ); -/// -- 确认主库可查后再按需清理业务库副本(谨慎执行): -/// -- DELETE FROM `antis-foodlabeling-us`.YiTenant WHERE Name IN ('mike', '中国麦当劳公司'); -/// -/// [Authorize] public class ThTenantProvisioningAppService : ApplicationService, IThTenantProvisioningAppService { @@ -46,48 +31,116 @@ public class ThTenantProvisioningAppService : ApplicationService, IThTenantProvi private readonly ITenantService _tenantService; private readonly IThTenantDatabaseBackgroundInitializer _backgroundInitializer; + private readonly ISqlSugarRepository _credentialRepository; + private readonly ISqlSugarRepository _userRepository; + private readonly TenantSelectCredentialCipher _credentialCipher; + private readonly TenantAdminAccountBootstrapper _adminAccountBootstrapper; + private readonly ITenantCompanyMenuSyncService _companyMenuSyncService; private readonly FoodLabelingThTenantDatabaseOptions _dbOptions; private readonly DbConnOptions _dbConnOptions; + private readonly RbacOptions _rbacOptions; public ThTenantProvisioningAppService( ITenantService tenantService, IThTenantDatabaseBackgroundInitializer backgroundInitializer, + ISqlSugarRepository credentialRepository, + ISqlSugarRepository userRepository, + TenantSelectCredentialCipher credentialCipher, + TenantAdminAccountBootstrapper adminAccountBootstrapper, + ITenantCompanyMenuSyncService companyMenuSyncService, IOptions dbOptions, - IOptions dbConnOptions) + IOptions dbConnOptions, + IOptions rbacOptions) { _tenantService = tenantService; _backgroundInitializer = backgroundInitializer; + _credentialRepository = credentialRepository; + _userRepository = userRepository; + _credentialCipher = credentialCipher; + _adminAccountBootstrapper = adminAccountBootstrapper; + _companyMenuSyncService = companyMenuSyncService; _dbOptions = dbOptions.Value; _dbConnOptions = dbConnOptions.Value; + _rbacOptions = rbacOptions.Value; } - /// - public virtual async Task ProvisionAsync(ThProvisionTenantInputVo input) + /// + /// 新增租户(开通公司):登记主库租户、校验登录邮箱唯一性、写入管理员凭据,可选后台初始化业务库 + /// + /// + /// 邮箱作为该公司 Web 登录账号,必填且全局唯一(不可与平台账号、其他公司管理员 LoginAccount 重复)。 + /// 兼容字段:可用 emailadminUserName(邮箱形态)二选一。 + /// + /// 路由: + /// - POST /api/app/th-tenant-provisioning/provision(前端默认) + /// - POST /api/app/th-tenant-provisioning(简写,同义) + /// + /// 示例请求: + /// ```json + /// { + /// "name": "中国麦当劳公司", + /// "email": "mcdonalds.admin@example.com", + /// "adminPassword": "ChangeMe123!", + /// "initializeDatabase": true + /// } + /// ``` + /// + /// 参数说明: + /// - name: 租户/公司名称(必填) + /// - email / adminUserName: 管理员登录邮箱(必填,唯一) + /// - adminPassword: 可选初始密码;为空用系统默认 AdminPassword + /// - tenantConnectionString / databaseKey / dbType / initializeDatabase: 库相关可选参数 + /// + /// 开通参数 + /// 租户 Id、邮箱、库信息与初始化状态 + /// 创建成功(业务库可能仍在后台初始化) + /// 名称/邮箱缺失、邮箱格式错误或邮箱已被占用 + /// 未登录 + /// 服务器错误 + [HttpPost("th-tenant-provisioning/provision")] + [HttpPost("th-tenant-provisioning")] + public virtual async Task ProvisionAsync([FromBody] ThProvisionTenantInputVo input) { if (string.IsNullOrWhiteSpace(input.Name)) { throw new UserFriendlyException("租户名称不能为空"); } + var email = NormalizeAndValidateEmail( + !string.IsNullOrWhiteSpace(input.Email) ? input.Email : input.AdminUserName); + await EnsureLoginEmailUniqueAsync(email); + + var plainPassword = string.IsNullOrWhiteSpace(input.AdminPassword) + ? (_rbacOptions.AdminPassword ?? string.Empty) + : input.AdminPassword.Trim(); + if (string.IsNullOrEmpty(plainPassword)) + { + throw new UserFriendlyException("管理员初始密码不能为空(请传 adminPassword 或配置 RbacOptions.AdminPassword)"); + } + + // 未显式指定 DatabaseKey / 连接串时,名称 hash 后追加短 Guid,避免同名公司复用同一业务库 + var effectiveDatabaseKey = string.IsNullOrWhiteSpace(input.DatabaseKey) + && string.IsNullOrWhiteSpace(input.TenantConnectionString) + ? TenantDatabaseConnectionStringBuilder.BuildUniqueTenantDatabaseKey(input.Name) + : input.DatabaseKey; + var connectionString = string.IsNullOrWhiteSpace(input.TenantConnectionString) ? TenantDatabaseConnectionStringBuilder.BuildMySqlConnectionString( _dbOptions, input.Name, - input.DatabaseKey) + effectiveDatabaseKey) : input.TenantConnectionString.Trim(); var databaseName = ExtractDatabaseName(connectionString) ?? TenantDatabaseConnectionStringBuilder.BuildDatabaseName( _dbOptions, input.Name, - input.DatabaseKey); + effectiveDatabaseKey); - // 平台主库写入 yitenant(CurrentTenant 为空时走 DbConnOptions 主库) var dbType = Enum.IsDefined(typeof(DbType), input.DbType) ? (DbType)input.DbType : DbType.MySql; - // YiTenant 必须写入主库;Create 与 Init 分事务提交,Init 经 TenantStore 读主库需可见记录 TenantGetOutputDto created; using (CurrentTenant.Change(null)) using (var uow = UnitOfWorkManager.Begin(requiresNew: true, isTransactional: true)) @@ -98,13 +151,14 @@ public class ThTenantProvisioningAppService : ApplicationService, IThTenantProvi TenantConnectionString = connectionString, DbType = dbType }); + + await InsertAdminCredentialAsync(created.Id, email, plainPassword); await uow.CompleteAsync(); } var initializing = false; if (input.InitializeDatabase) { - // 同步建库 + GRANT,避免仅入队后台 Init 时库不存在导致静默失败 TenantDatabaseBootstrapper.EnsureDatabaseCreated( _dbConnOptions, dbType, @@ -119,6 +173,7 @@ public class ThTenantProvisioningAppService : ApplicationService, IThTenantProvi { TenantId = created.Id, Name = created.Name, + Email = email, DatabaseName = databaseName, TenantConnectionString = connectionString, DatabaseInitialized = false, @@ -126,11 +181,95 @@ public class ThTenantProvisioningAppService : ApplicationService, IThTenantProvi }; } - /// + /// + /// 对已有租户同步执行业务库 CodeFirst,并同步管理员登录邮箱 + /// + /// + /// 路由:POST /api/app/th-tenant-provisioning/initialize-tenant-database/{tenantId} + /// 亦支持 query:?tenantId=(与前端一致)。 + /// + /// 租户 Id + /// 初始化成功 + /// 租户无效或业务库不可用 + /// 未登录 + /// 服务器错误 + [HttpPost("th-tenant-provisioning/initialize-tenant-database/{tenantId}")] + [HttpPost("th-tenant-provisioning/initialize-tenant-database")] [RequestTimeout(TenantDatabaseInitRequestTimeoutPolicy)] - public virtual Task InitializeTenantDatabaseAsync(Guid tenantId) + public virtual async Task InitializeTenantDatabaseAsync([FromRoute] Guid tenantId, [FromQuery] Guid? tenantIdQuery = null) { - return _tenantService.InitAsync(tenantId); + var id = tenantId != Guid.Empty ? tenantId : (tenantIdQuery ?? Guid.Empty); + if (id == Guid.Empty) + { + throw new UserFriendlyException("租户 Id 不能为空"); + } + + await _tenantService.InitAsync(id); + await _adminAccountBootstrapper.ApplyLoginEmailAsync(id); + await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(id); + await _companyMenuSyncService.EnsureAdminRoleMenusSyncedAsync(id); + } + + private static string NormalizeAndValidateEmail(string? email) + { + if (string.IsNullOrWhiteSpace(email)) + { + throw new UserFriendlyException("登录邮箱不能为空"); + } + + var normalized = email.Trim(); + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(normalized)) + { + throw new UserFriendlyException("登录邮箱格式不正确"); + } + + return normalized; + } + + /// + /// 邮箱全局唯一:主库平台用户 + 各公司管理员 LoginAccount。 + /// + private async Task EnsureLoginEmailUniqueAsync(string email) + { + var normalized = email.Trim().ToLowerInvariant(); + + using (CurrentTenant.Change(null)) + { + var hostOccupied = await _userRepository._DbQueryable + .AnyAsync(u => !u.IsDeleted + && ((u.Email != null && SqlFunc.ToLower(u.Email) == normalized) + || SqlFunc.ToLower(u.UserName) == normalized)); + if (hostOccupied) + { + throw new UserFriendlyException($"登录邮箱「{email}」已被平台账号占用,请更换"); + } + + var companyOccupied = await _credentialRepository._DbQueryable + .AnyAsync(c => c.LoginAccount != null + && SqlFunc.ToLower(c.LoginAccount) == normalized); + if (companyOccupied) + { + throw new UserFriendlyException($"登录邮箱「{email}」已被其他公司管理员占用,请更换"); + } + } + } + + private async Task InsertAdminCredentialAsync(Guid tenantId, string email, string plainPassword) + { + var encrypted = _credentialCipher.EncryptPassword(plainPassword); + var now = DateTime.Now; + await _credentialRepository._Db.Ado.ExecuteCommandAsync( + @"INSERT INTO `fl_th_tenant_admin_credential` + (`TenantId`, `LoginAccount`, `PasswordCipher`, `PasswordIv`, `LastModificationTime`) + VALUES (@TenantId, @LoginAccount, @PasswordCipher, @PasswordIv, @LastModificationTime)", + new List + { + new("@TenantId", tenantId.ToString()), + new("@LoginAccount", email), + new("@PasswordCipher", encrypted.Password), + new("@PasswordIv", encrypted.PasswordSalt), + new("@LastModificationTime", now) + }); } private static string? ExtractDatabaseName(string connectionString) diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantScopedRbacMenuAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantScopedRbacMenuAppService.cs new file mode 100644 index 0000000..a433460 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantScopedRbacMenuAppService.cs @@ -0,0 +1,50 @@ +using FoodLabeling.Application.Contracts.IServices; +using FoodLabeling.Application.Helpers; +using FoodLabeling.Application.Services; +using FoodLabeling.Application.Services.DbModels; +using FoodLabeling.Th.Application.MultiTenancy; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Application.Services; + +/// +/// 泰额版:租户内 /api/app/rbac-menu/tree 仅返回公司已开通菜单(及祖先)。 +/// +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IRbacMenuAppService), typeof(RbacMenuAppService))] +public class ThTenantScopedRbacMenuAppService : RbacMenuAppService +{ + private readonly DbConnOptions _dbConnOptions; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ThTenantScopedRbacMenuAppService( + ISqlSugarDbContext dbContext, + IOptions dbConnOptions, + IHttpContextAccessor httpContextAccessor) + : base(dbContext) + { + _dbConnOptions = dbConnOptions.Value; + _httpContextAccessor = httpContextAccessor; + } + + /// + protected override async Task> LoadActiveMenusForTreeAsync() + { + var menus = await base.LoadActiveMenusForTreeAsync(); + var tenantId = TenantBusinessContextHelper.ResolveBusinessTenantId( + CurrentTenant, + _httpContextAccessor.HttpContext); + if (!TenantBusinessContextHelper.ShouldScopeMenusByCompany(_dbConnOptions, tenantId)) + { + return menus; + } + + var enabledMenuIds = await TenantCompanyMenuScopeHelper.LoadEnabledMenuIdsAsync( + _dbConnOptions, + tenantId!.Value); + return PlatformMenuHelper.FilterCompanyMenusWithAncestors(menus, enabledMenuIds); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantScopedRbacRoleAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantScopedRbacRoleAppService.cs new file mode 100644 index 0000000..01c1b79 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantScopedRbacRoleAppService.cs @@ -0,0 +1,86 @@ +using FoodLabeling.Application.Contracts.IServices; +using FoodLabeling.Application.Helpers; +using FoodLabeling.Application.Services; +using FoodLabeling.Th.Application.MultiTenancy; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using SqlSugar; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Yi.Framework.Rbac.Domain.Entities; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Application.Services; + +/// +/// 泰额版:公司租户上下文绑定角色菜单时,不得超过 fl_th_tenant_menu_permission 已开通范围(及祖先)。 +/// +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IRbacRoleAppService), typeof(RbacRoleAppService))] +public class ThTenantScopedRbacRoleAppService : RbacRoleAppService +{ + private readonly DbConnOptions _dbConnOptions; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ThTenantScopedRbacRoleAppService( + ISqlSugarDbContext dbContext, + ISqlSugarRepository roleRepository, + ISqlSugarRepository menuRepository, + ISqlSugarRepository roleMenuRepository, + ISqlSugarRepository roleDeptRepository, + ISqlSugarRepository userRoleRepository, + IOptions dbConnOptions, + IHttpContextAccessor httpContextAccessor) + : base( + dbContext, + roleRepository, + menuRepository, + roleMenuRepository, + roleDeptRepository, + userRoleRepository) + { + _dbConnOptions = dbConnOptions.Value; + _httpContextAccessor = httpContextAccessor; + } + + /// + protected override async Task SetRoleMenusAsync(Guid roleId, List menuIds) + { + var tenantId = TenantBusinessContextHelper.ResolveBusinessTenantId( + CurrentTenant, + _httpContextAccessor.HttpContext); + if (TenantBusinessContextHelper.ShouldScopeMenusByCompany(_dbConnOptions, tenantId)) + { + await ValidateCompanyMenuScopeAsync(tenantId!.Value, menuIds); + } + + await base.SetRoleMenusAsync(roleId, menuIds); + } + + private async Task ValidateCompanyMenuScopeAsync(Guid tenantId, List menuIds) + { + if (menuIds.Count == 0) + { + return; + } + + var allowedMenuIds = await TenantCompanyMenuScopeHelper.LoadAllowedMenuIdsWithAncestorsAsync( + _dbConnOptions, + tenantId); + if (allowedMenuIds.Count == 0) + { + throw new UserFriendlyException("该公司尚未开通任何菜单,请先在「菜单权限」中分配"); + } + + var outOfScope = menuIds + .Select(x => x.ToString()) + .Where(x => !allowedMenuIds.Contains(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (outOfScope.Count > 0) + { + throw new UserFriendlyException( + $"角色菜单超出公司已开通范围:{string.Join(", ", outOfScope)}"); + } + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThUsAppAuthAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThUsAppAuthAppService.cs new file mode 100644 index 0000000..12bd2b8 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThUsAppAuthAppService.cs @@ -0,0 +1,85 @@ +using FoodLabeling.Application.Contracts; +using FoodLabeling.Application.Contracts.Dtos.UsAppAuth; +using FoodLabeling.Application.Contracts.IServices; +using FoodLabeling.Application.Services; +using FoodLabeling.Th.Application.Contracts.Dtos.Auth; +using FoodLabeling.Th.Application.Contracts.IServices; +using Lazy.Captcha.Core; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; +using Yi.Framework.Rbac.Application.Contracts.IServices; +using Yi.Framework.Rbac.Domain.Entities; +using Yi.Framework.Rbac.Domain.Managers; +using Yi.Framework.Rbac.Domain.Shared.Options; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Application.Services; + +/// +/// 泰额多租户:兼容 App 仍调用 us-app-auth/login,内部按邮箱反查公司并走租户库登录。 +/// +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IUsAppAuthAppService), typeof(UsAppAuthAppService))] +public class ThUsAppAuthAppService : UsAppAuthAppService +{ + private readonly IThAppAuthAppService _thAppAuth; + private readonly DbConnOptions _saasDbConnOptions; + + public ThUsAppAuthAppService( + IAccountManager accountManager, + ISqlSugarRepository userRepository, + ISqlSugarDbContext dbContext, + IHttpContextAccessor httpContextAccessor, + ICaptcha captcha, + IOptions jwtOptions, + IOptions rbacOptions, + IForgotPasswordByEmailService forgotPasswordByEmailService, + IDistributedCache distributedCache, + IOptions dbConnOptions, + IThAppAuthAppService thAppAuth) + : base( + accountManager, + userRepository, + dbContext, + httpContextAccessor, + captcha, + jwtOptions, + rbacOptions, + forgotPasswordByEmailService, + distributedCache, + dbConnOptions) + { + _thAppAuth = thAppAuth; + _saasDbConnOptions = dbConnOptions.Value; + } + + /// + /// + /// SaaS 开启时转发至 th-app-auth/login 同等逻辑(邮箱反查租户 + JWT 含 TenantId), + /// 出参仍为 ,前端路径可不变。 + /// + public override async Task LoginAsync(UsAppLoginInputVo input) + { + if (!_saasDbConnOptions.EnabledSaasMultiTenancy) + { + return await base.LoginAsync(input); + } + + var thResult = await _thAppAuth.LoginAsync(new ThAppLoginInputVo + { + Email = input.Email, + Password = input.Password, + Uuid = input.Uuid, + Code = input.Code + }); + + return new UsAppLoginOutputDto + { + Token = thResult.Token, + RefreshToken = thResult.RefreshToken, + Locations = thResult.Locations + }; + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs index 4b9ef60..5bd5537 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs @@ -14,6 +14,7 @@ using Yi.Framework.Rbac.Domain.Entities; using Yi.Framework.Rbac.Domain.Managers; using Yi.Framework.Rbac.Domain.Shared.Options; using Yi.Framework.SqlSugarCore.Abstractions; +using Yi.Framework.TenantManagement.Application.Contracts; namespace FoodLabeling.Th.Application.Services; @@ -24,21 +25,33 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService { private readonly IAccountManager _accountManager; private readonly ISqlSugarRepository _userRepository; + private readonly TenantAdminAccountBootstrapper _adminAccountBootstrapper; + private readonly IThTenantDatabaseBackgroundInitializer _backgroundInitializer; + private readonly ITenantService _tenantService; private readonly ICaptcha _captcha; private readonly RbacOptions _rbacOptions; + private readonly DbConnOptions _dbConnOptions; private readonly ITenantStore _tenantStore; public ThWebAuthAppService( IAccountManager accountManager, ISqlSugarRepository userRepository, + TenantAdminAccountBootstrapper adminAccountBootstrapper, + IThTenantDatabaseBackgroundInitializer backgroundInitializer, + ITenantService tenantService, ICaptcha captcha, IOptions rbacOptions, + IOptions dbConnOptions, ITenantStore tenantStore) { _accountManager = accountManager; _userRepository = userRepository; + _adminAccountBootstrapper = adminAccountBootstrapper; + _backgroundInitializer = backgroundInitializer; + _tenantService = tenantService; _captcha = captcha; _rbacOptions = rbacOptions.Value; + _dbConnOptions = dbConnOptions.Value; _tenantStore = tenantStore; } @@ -48,21 +61,23 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService /// /// 校验租户后在租户独立库验证账号,签发含 TenantId 与 RBAC 权限的 JWT。 /// - /// 泰额 H5 与公司 Web 共用此接口。选 **Default** 或 tenantId 为空时,若邮箱账号存在于主库则走**平台登录**(JWT 无 TenantId); - /// 选具体公司 tenantId 时在该公司业务库校验。Default 业务库(如 US)仅在主库无该邮箱时作为回落。 + /// 泰额 H5 与公司 Web 共用此接口。选 **Default** 或 tenantId 为空时: + /// 1) 主库平台邮箱优先走平台登录; + /// 2) 否则按开通邮箱(fl_th_tenant_admin_credential)或各租户业务库 user 表反查公司租户,支持 Team Member 不选公司、仅邮箱/用户名密码登录; + /// 3) 再回落 Default 业务库。 + /// 选具体公司 tenantId 时在该公司业务库校验;若登录标识为邮箱且已存在于主库(平台管理员邮箱),则拒绝并提示改用 Default。 /// - /// 示例请求(平台,选 Default): + /// 示例请求(公司管理员,可不传 tenantId): /// ```json /// { - /// "tenantId": "11111111-1111-1111-1111-111111111111", - /// "userName": "admin@example.com", + /// "userName": "mai@123.com", /// "password": "123456" /// } /// ``` /// /// 参数说明: - /// - tenantId: 可选,租户 Guid(与 tenantName 二选一) - /// - tenantName: 可选,租户名称(与 tenantId 二选一) + /// - tenantId: 可选,租户 Guid(与 tenantName 二选一;均可空,邮箱可反查公司) + /// - tenantName: 可选,租户名称 /// - userName: 登录账号或邮箱 /// - password: 明文密码 /// @@ -83,7 +98,7 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService ValidationImageCaptcha(input.Uuid, input.Code); - // H5 登录页选 Default / 空 tenantId:优先主库平台账号(admin@example.com),避免误入 Default 业务库(如 US) + // H5 登录页选 Default / 空 tenantId:优先主库平台账号;否则按开通邮箱反查公司 if (ThWebPlatformLoginHelper.ShouldTryPlatformLogin(input.TenantId, input.TenantName)) { var platformLogin = await TryPlatformLoginAsync(input); @@ -92,11 +107,33 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService return platformLogin; } - if (!input.TenantId.HasValue || input.TenantId.Value == Guid.Empty) + var companyTenantId = await ThLoginTenantResolver.TryResolveCompanyTenantIdByLoginAsync( + _dbConnOptions, + input.UserName, + Logger); + if (companyTenantId.HasValue) + { + input.TenantId = companyTenantId; + input.TenantName = null; + } + else if (!string.IsNullOrWhiteSpace(input.TenantName) + && !string.Equals( + input.TenantName.Trim(), + ThWebPlatformLoginHelper.DefaultTenantName, + StringComparison.OrdinalIgnoreCase)) + { + // 仅传了公司名称、未传 Id:按名称解析,勿强行落到 Default + input.TenantId = null; + } + else if (!input.TenantId.HasValue || input.TenantId.Value == Guid.Empty) { input.TenantId = ThWebPlatformLoginHelper.DefaultTenantId; } } + else + { + await EnsureNotHostPlatformAccountAsync(input.UserName); + } TenantConfiguration tenantConfig; try @@ -118,12 +155,18 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService try { + // 开通后后台 Seed/建表可能尚未完成;登录前确保业务库就绪并补齐开通邮箱 + await EnsureTenantBusinessDatabaseReadyAsync(tenantId); + await EnsureTenantAdminLoginSyncedAsync(tenantId, input.UserName.Trim()); + using (CurrentTenant.Change(tenantId, tenantConfig.Name)) { var user = await FindActiveUserByEmailAsync(input.UserName.Trim()); if (user == null) { - throw new UserFriendlyException("登录失败:账号不存在或已禁用"); + var initMessage = await ResolveTenantNotReadyLoginMessageAsync(tenantId); + throw new UserFriendlyException( + initMessage ?? "登录失败:账号不存在或已禁用"); } if (!user.JudgePassword(input.Password)) @@ -150,9 +193,42 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService catch (Exception ex) { Logger.LogError(ex, "租户 {TenantId}/{TenantName} Web 登录异常", tenantId, tenantConfig.Name); - var detail = ex.GetBaseException().Message; + if (IsTenantDatabaseNotReady(ex)) + { + throw new UserFriendlyException( + await ResolveTenantNotReadyLoginMessageAsync(tenantId) + ?? "登录失败:租户业务库尚未初始化完成,请稍候再试"); + } + throw new UserFriendlyException( - $"登录失败:租户业务库不可用或未初始化完成。{detail}"); + "登录失败:租户业务库初始化异常,请联系管理员处理"); + } + } + + /// + /// 业务租户登录前校验:主库平台邮箱账号不得落入公司业务库。 + /// 仅拦截邮箱形态(与 TryPlatformLoginAsync 一致);租户默认 UserName=admin 与主库 admin 同名时允许走租户库。 + /// + private async Task EnsureNotHostPlatformAccountAsync(string userName) + { + if (string.IsNullOrWhiteSpace(userName)) + { + return; + } + + // 租户管理员默认账号常为 admin,与主库平台 UserName 同名;不可按用户名拦截 + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(userName)) + { + return; + } + + using (CurrentTenant.Change(null)) + { + var hostUser = await FindActiveUserByEmailAsync(userName.Trim()); + if (hostUser != null) + { + throw new UserFriendlyException(ThWebPlatformLoginHelper.PlatformAccountMustUseDefaultMessage); + } } } @@ -206,6 +282,127 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService } } + /// + /// 开通后后台建表未完成时,登录会报 User 表不存在;此处探测并补初始化。 + /// 后台任务进行中但 User 已可查时允许登录,避免误报「正在初始化」。 + /// + private async Task EnsureTenantBusinessDatabaseReadyAsync(Guid tenantId) + { + try + { + using (CurrentTenant.Change(tenantId)) + { + await _userRepository._DbQueryable.Take(1).Select(u => u.Id).ToListAsync(); + } + + return; + } + catch (Exception ex) when (IsTenantDatabaseNotReady(ex)) + { + if (_backgroundInitializer.IsRunning(tenantId)) + { + throw new UserFriendlyException("登录失败:租户业务库正在初始化,请稍候再试"); + } + + Logger.LogWarning(ex, "租户 {TenantId} 业务库未就绪,登录前同步初始化", tenantId); + await _tenantService.InitAsync(tenantId); + await _adminAccountBootstrapper.ApplyLoginEmailAsync(tenantId); + await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId); + } + } + + /// + /// 开通邮箱已写入主库凭据、但业务库仍为 Seed 的 admin 时,登录前补同步一次。 + /// + private async Task EnsureTenantAdminLoginSyncedAsync(Guid tenantId, string loginAccount) + { + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(loginAccount)) + { + return; + } + + var normalized = loginAccount.Trim().ToLowerInvariant(); + var credentialAccount = await _adminAccountBootstrapper.GetProvisionedLoginAccountAsync(tenantId); + + if (string.IsNullOrWhiteSpace(credentialAccount) + || !string.Equals(credentialAccount, loginAccount.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + using (CurrentTenant.Change(tenantId)) + { + var existing = await FindActiveUserByEmailAsync(loginAccount); + if (existing != null) + { + await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId); + return; + } + } + } + catch (Exception ex) when (IsTenantDatabaseNotReady(ex)) + { + Logger.LogWarning(ex, "租户 {TenantId} 查询用户失败(库未就绪),跳过邮箱探测", tenantId); + } + + try + { + await _adminAccountBootstrapper.ApplyLoginEmailAsync(tenantId); + await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId); + } + catch (Exception ex) + { + Logger.LogWarning( + ex, + "租户 {TenantId} 登录前同步管理员邮箱 {LoginAccount} 失败", + tenantId, + normalized); + } + } + + /// + /// 租户库未就绪 / 初始化中时返回明确文案;账号确实不存在则返回 null。 + /// + private async Task ResolveTenantNotReadyLoginMessageAsync(Guid tenantId) + { + if (_backgroundInitializer.IsRunning(tenantId)) + { + return "登录失败:租户业务库正在初始化,请稍候再试"; + } + + try + { + using (CurrentTenant.Change(tenantId)) + { + var activeUserCount = await _userRepository._DbQueryable + .CountAsync(u => !u.IsDeleted); + if (activeUserCount == 0) + { + return "登录失败:租户业务库尚未初始化完成,请稍候再试"; + } + } + } + catch (Exception ex) when (IsTenantDatabaseNotReady(ex)) + { + return "登录失败:租户业务库尚未初始化完成,请稍候再试"; + } + + return null; + } + + private static bool IsTenantDatabaseNotReady(Exception ex) + { + var message = ex.GetBaseException().Message; + return message.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase) + || message.Contains("Unknown database", StringComparison.OrdinalIgnoreCase) + || message.Contains("Unknown Database", StringComparison.OrdinalIgnoreCase) + || message.Contains("Unknown table", StringComparison.OrdinalIgnoreCase) + || (message.Contains("Table", StringComparison.OrdinalIgnoreCase) + && message.Contains("exist", StringComparison.OrdinalIgnoreCase)); + } + private async Task FindActiveUserByEmailAsync(string email) { var normalized = email.Trim().ToLowerInvariant(); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain/Authorization/RefreshTokenMiddleware.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain/Authorization/RefreshTokenMiddleware.cs index cdd4883..b268bad 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain/Authorization/RefreshTokenMiddleware.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain/Authorization/RefreshTokenMiddleware.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -9,6 +10,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; using Volo.Abp.Security.Claims; using Yi.Framework.Rbac.Domain.Managers; using Yi.Framework.Rbac.Domain.Shared.Consts; @@ -18,11 +20,13 @@ namespace Yi.Framework.Rbac.Domain.Authorization [DebuggerStepThrough] public class RefreshTokenMiddleware : IMiddleware, ITransientDependency { - private AccountManager _accountManager; - public RefreshTokenMiddleware(AccountManager accountManager) - { + private readonly AccountManager _accountManager; + private readonly ICurrentTenant _currentTenant; + public RefreshTokenMiddleware(AccountManager accountManager, ICurrentTenant currentTenant) + { _accountManager = accountManager; + _currentTenant = currentTenant; } public async Task InvokeAsync(HttpContext context, RequestDelegate next) @@ -36,18 +40,75 @@ namespace Yi.Framework.Rbac.Domain.Authorization if (authResult.Succeeded) { var userId = Guid.Parse(authResult.Principal.FindFirst(AbpClaimTypes.UserId).Value.ToString()); - var access_Token = await _accountManager.GetTokenByUserIdAsync(userId); - var refresh_Token = _accountManager.CreateRefreshToken(userId); - context.Response.Headers["access_token"] = access_Token; - context.Response.Headers["refresh_token"] = refresh_Token; - + var tenantId = TryResolveTenantIdFromRequest(context); + using (tenantId.HasValue + ? _currentTenant.Change(tenantId.Value) + : _currentTenant.Change(null)) + { + var access_Token = await _accountManager.GetTokenByUserIdAsync(userId); + var refresh_Token = _accountManager.CreateRefreshToken(userId); + context.Response.Headers["access_token"] = access_Token; + context.Response.Headers["refresh_token"] = refresh_Token; + } //请求头替换,补充后续鉴权逻辑 - context.Request.Headers["Authorization"] = "Bearer " + access_Token; + context.Request.Headers["Authorization"] = "Bearer " + context.Response.Headers["access_token"]; } } await next(context); } + + /// + /// 刷新 access token 时保留业务租户上下文(__tenant 或旧 JWT 中的 TenantId)。 + /// + private static Guid? TryResolveTenantIdFromRequest(HttpContext context) + { + if (context.Request.Headers.TryGetValue("__tenant", out var headerVal)) + { + var headerText = headerVal.ToString(); + if (Guid.TryParse(headerText, out var fromHeader) && fromHeader != Guid.Empty) + { + return fromHeader; + } + } + + var authorization = context.Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(authorization)) + { + return null; + } + + const string bearerPrefix = "Bearer "; + if (!authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var jwt = authorization[bearerPrefix.Length..].Trim(); + if (string.IsNullOrWhiteSpace(jwt)) + { + return null; + } + + try + { + var token = new JwtSecurityTokenHandler().ReadJwtToken(jwt); + var tenantClaim = token.Claims.FirstOrDefault(c => + c.Type == TokenTypeConst.TenantId + || c.Type == AbpClaimTypes.TenantId) + ?.Value; + if (Guid.TryParse(tenantClaim, out var tenantId) && tenantId != Guid.Empty) + { + return tenantId; + } + } + catch (Exception) + { + return null; + } + + return null; + } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/RoleDataSeed.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/RoleDataSeed.cs index 82cfb5c..f7d27d8 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/RoleDataSeed.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/RoleDataSeed.cs @@ -6,6 +6,9 @@ using Yi.Framework.SqlSugarCore.Abstractions; namespace Yi.Framework.Rbac.SqlSugarCore.DataSeeds { + /// + /// 初始化角色。Host 主库保留调试角色;租户业务库仅产出 admin / companyAdmin / staff。 + /// public class RoleDataSeed : IDataSeedContributor, ITransientDependency { private ISqlSugarRepository _repository; @@ -14,7 +17,10 @@ namespace Yi.Framework.Rbac.SqlSugarCore.DataSeeds _repository = repository; } - public List GetSeedData() + /// + /// Host 主库种子:admin + test/common/default(本地调试)。 + /// + public List GetHostSeedData() { var entities = new List(); RoleAggregateRoot role1 = new RoleAggregateRoot() @@ -69,11 +75,51 @@ namespace Yi.Framework.Rbac.SqlSugarCore.DataSeeds return entities; } + /// + /// 租户业务库种子:仅 admin(配菜单)、companyAdmin、staff(无初始菜单)。 + /// + public List GetTenantSeedData() + { + return new List + { + new RoleAggregateRoot + { + RoleName = "管理员", + RoleCode = "admin", + DataScope = DataScopeEnum.ALL, + OrderNum = 999, + Remark = "管理员", + IsDeleted = false + }, + new RoleAggregateRoot + { + RoleName = "公司管理员", + RoleCode = "companyAdmin", + DataScope = DataScopeEnum.ALL, + OrderNum = 100, + Remark = "公司管理员", + IsDeleted = false + }, + new RoleAggregateRoot + { + RoleName = "员工", + RoleCode = "staff", + DataScope = DataScopeEnum.ALL, + OrderNum = 10, + Remark = "员工", + IsDeleted = false + } + }; + } + public async Task SeedAsync(DataSeedContext context) { if (!await _repository.IsAnyAsync(x => true)) { - await _repository.InsertManyAsync(GetSeedData()); + var seedData = context.TenantId.HasValue + ? GetTenantSeedData() + : GetHostSeedData(); + await _repository.InsertManyAsync(seedData); } } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/UserDataSeed.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/UserDataSeed.cs index f855541..8ea9861 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/UserDataSeed.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/UserDataSeed.cs @@ -42,48 +42,51 @@ namespace Yi.Framework.Rbac.SqlSugarCore.DataSeeds user1.BuildPassword(); entities.Add(user1); - UserAggregateRoot user2 = new UserAggregateRoot() + // 业务租户库仅初始化管理员;Host 主库保留 test/guest 便于本地调试 + if (!context.TenantId.HasValue) { + UserAggregateRoot user2 = new UserAggregateRoot() + { - Name = "测试", - UserName = "test", - Nick = "测试", - EncryPassword=new EncryPasswordValueObject(_options.AdminPassword), - Email = "test@example.com", - Phone = 15900000000, - Sex = SexEnum.Woman, - Address = "成都", - Age = 18, - Introduction = "测试", - OrderNum = 1, - Remark = "测试", - State = true + Name = "测试", + UserName = "test", + Nick = "测试", + EncryPassword=new EncryPasswordValueObject(_options.AdminPassword), + Email = "test@example.com", + Phone = 15900000000, + Sex = SexEnum.Woman, + Address = "成都", + Age = 18, + Introduction = "测试", + OrderNum = 1, + Remark = "测试", + State = true - }; - user2.BuildPassword(); - entities.Add(user2); + }; + user2.BuildPassword(); + entities.Add(user2); - UserAggregateRoot user3 = new UserAggregateRoot() - { + UserAggregateRoot user3 = new UserAggregateRoot() + { - Name = "游客", - UserName = "guest", - Nick = "测试", - EncryPassword = new EncryPasswordValueObject("123456"), - Email = "454313500@qq.com", - Phone = 15900000000, - Sex = SexEnum.Woman, - Address = "深圳", - Age = 18, - Introduction = "临时游客", - OrderNum = 1, - Remark = "懒得创账号", - State = true - - }; - user3.BuildPassword(); - entities.Add(user3); + Name = "游客", + UserName = "guest", + Nick = "测试", + EncryPassword = new EncryPasswordValueObject("123456"), + Email = "454313500@qq.com", + Phone = 15900000000, + Sex = SexEnum.Woman, + Address = "深圳", + Age = 18, + Introduction = "临时游客", + OrderNum = 1, + Remark = "懒得创账号", + State = true + }; + user3.BuildPassword(); + entities.Add(user3); + } await _repository.InsertManyAsync(entities); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/UserRoleDataSeed.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/UserRoleDataSeed.cs new file mode 100644 index 0000000..624eb3f --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.SqlSugarCore/DataSeeds/UserRoleDataSeed.cs @@ -0,0 +1,95 @@ +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Yi.Framework.Rbac.Domain.Entities; +using Yi.Framework.Rbac.Domain.Shared.Consts; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace Yi.Framework.Rbac.SqlSugarCore.DataSeeds; + +/// +/// 初始化用户与角色的默认绑定(admin/test/guest)。 +/// +public class UserRoleDataSeed : IDataSeedContributor, ITransientDependency +{ + private readonly ISqlSugarRepository _userRoleRepository; + private readonly ISqlSugarRepository _userRepository; + private readonly ISqlSugarRepository _roleRepository; + + public UserRoleDataSeed( + ISqlSugarRepository userRoleRepository, + ISqlSugarRepository userRepository, + ISqlSugarRepository roleRepository) + { + _userRoleRepository = userRoleRepository; + _userRepository = userRepository; + _roleRepository = roleRepository; + } + + public async Task SeedAsync(DataSeedContext context) + { + if (await _userRoleRepository.IsAnyAsync(x => true)) + { + return; + } + + var isTenantDb = context.TenantId.HasValue; + + var users = await _userRepository._DbQueryable + .Where(u => !u.IsDeleted) + .Where(u => u.UserName == UserConst.Admin + || (!isTenantDb && (u.UserName == "test" || u.UserName == "guest"))) + .ToListAsync(); + if (users.Count == 0) + { + return; + } + + var roles = await _roleRepository._DbQueryable + .Where(r => !r.IsDeleted) + .Where(r => r.RoleCode == UserConst.AdminRolesCode + || (!isTenantDb && (r.RoleCode == "test" || r.RoleCode == UserConst.DefaultRoleCode))) + .ToListAsync(); + if (roles.Count == 0) + { + return; + } + + var roleByCode = roles + .GroupBy(r => r.RoleCode, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var bindings = isTenantDb + ? new (string UserName, string RoleCode)[] + { + (UserConst.Admin, UserConst.AdminRolesCode) + } + : new (string UserName, string RoleCode)[] + { + (UserConst.Admin, UserConst.AdminRolesCode), + ("test", "test"), + ("guest", UserConst.DefaultRoleCode) + }; + + var entities = new List(); + foreach (var (userName, roleCode) in bindings) + { + var user = users.FirstOrDefault(u => + string.Equals(u.UserName, userName, StringComparison.OrdinalIgnoreCase)); + if (user is null || !roleByCode.TryGetValue(roleCode, out var role)) + { + continue; + } + + entities.Add(new UserRoleEntity + { + UserId = user.Id, + RoleId = role.Id + }); + } + + if (entities.Count > 0) + { + await _userRoleRepository.InsertRangeAsync(entities); + } + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application.Contracts/ITenantDatabaseMigrationContributor.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application.Contracts/ITenantDatabaseMigrationContributor.cs new file mode 100644 index 0000000..eb6766c --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application.Contracts/ITenantDatabaseMigrationContributor.cs @@ -0,0 +1,20 @@ +namespace Yi.Framework.TenantManagement.Application.Contracts; + +/// +/// 租户业务库 CodeFirst 之后的补充迁移(如 CodeFirst 无法覆盖的表/列、手工 SQL 脚本)。 +/// 由业务模块实现并注册到 DI; 在 Init 流程中统一调用。 +/// +public interface ITenantDatabaseMigrationContributor +{ + /// 执行顺序,数值越小越先执行。 + int Order { get; } + + /// + /// 在租户业务库上应用迁移(脚本需幂等,可重复执行)。 + /// + /// 租户与连接信息 + /// 取消令牌 + Task ApplyAsync( + TenantDatabaseMigrationContext context, + CancellationToken cancellationToken = default); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application.Contracts/TenantDatabaseMigrationContext.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application.Contracts/TenantDatabaseMigrationContext.cs new file mode 100644 index 0000000..16bfc4c --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application.Contracts/TenantDatabaseMigrationContext.cs @@ -0,0 +1,18 @@ +using SqlSugar; + +namespace Yi.Framework.TenantManagement.Application.Contracts; + +/// +/// 租户业务库 CodeFirst 完成后的迁移上下文。 +/// +public sealed class TenantDatabaseMigrationContext +{ + /// 租户 Id + public Guid TenantId { get; init; } + + /// 租户业务库数据库类型 + public DbType DbType { get; init; } + + /// 已成功完成 CodeFirst 的连接串(与建表时使用的连接一致) + public string ConnectionString { get; init; } = string.Empty; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application/TenantService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application/TenantService.cs index 4b8d1e2..de90ec4 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application/TenantService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application/TenantService.cs @@ -307,7 +307,7 @@ namespace Yi.Framework.TenantManagement.Application $"创建租户库失败(库={databaseName})。尝试结果:{string.Join(" | ", createErrors)}。" + $"说明:MySQL 报 Access denied to database 新建库名时,通常是账号没有 CREATE 权限(与能否连上主库无关)。" + $"请在 appsettings 的 DbConnOptions.AdminConnectionString 配置高权限账号连接串后重试;或手动执行:" + - $"CREATE DATABASE IF NOT EXISTS `{databaseName}` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; " + + $"CREATE DATABASE IF NOT EXISTS `{databaseName}` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; " + $"GRANT ALL PRIVILEGES ON `{databaseName}`.* TO '{userId}'@'%'; " + $"再 PUT /api/app/tenant/init/{tenant.Id}"); } @@ -321,6 +321,7 @@ namespace Yi.Framework.TenantManagement.Application tenant.TenantConnectionString); // 3) 建表:优先业务连接串;失败则用建库成功的高权限连接串切到目标库再建表 + string successfulConnectionString; try { InitTenantTables( @@ -329,6 +330,7 @@ namespace Yi.Framework.TenantManagement.Application tenant.TenantConnectionString, types, externalServices); + successfulConnectionString = tenant.TenantConnectionString; } catch (Exception tenantConnEx) { @@ -341,6 +343,7 @@ namespace Yi.Framework.TenantManagement.Application adminTenantConn, types, externalServices); + successfulConnectionString = adminTenantConn; } catch (Exception adminConnEx) { @@ -355,7 +358,47 @@ namespace Yi.Framework.TenantManagement.Application } } - await Task.CompletedTask; + // 4) CodeFirst 无法覆盖的补充迁移(如 [IgnoreCodeFirst] 表、手工 ALTER 脚本) + await ApplyTenantDatabaseMigrationsAsync(tenant, successfulConnectionString, service); + } + + /// + /// 调用已注册的 ,在租户业务库执行幂等 SQL 迁移。 + /// + private static async Task ApplyTenantDatabaseMigrationsAsync( + TenantAggregateRoot tenant, + string connectionString, + IServiceProvider service) + { + var contributors = service + .GetServices() + .OrderBy(x => x.Order) + .ToList(); + + if (contributors.Count == 0) + { + return; + } + + var context = new TenantDatabaseMigrationContext + { + TenantId = tenant.Id, + DbType = tenant.DbType, + ConnectionString = connectionString + }; + + foreach (var contributor in contributors) + { + try + { + await contributor.ApplyAsync(context); + } + catch (Exception ex) + { + throw new UserFriendlyException( + $"租户库 CodeFirst 后的补充迁移失败({contributor.GetType().Name}):{GetRootMessage(ex)}"); + } + } } private static readonly Regex SafeDatabaseNameRegex = @@ -398,7 +441,7 @@ namespace Yi.Framework.TenantManagement.Application createDb.Ado.ExecuteCommand( $"CREATE DATABASE IF NOT EXISTS `{databaseName}` " + - "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"); } /// diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json index f041cd5..d42b098 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json @@ -20,7 +20,7 @@ }, //应用启动:SelfUrl 供 Program.cs UseUrls 绑定;用 0.0.0.0 避免写成固定局域网 IP 在本机无该网卡时启动失败(WinError 10049) "App": { - "SelfUrl": "http://192.168.31.88:19002", + "SelfUrl": "http://192.168.31.87:19002", "CorsOrigins": "http://localhost:19002;http://localhost:18000;http://localhost:5666;http://localhost:5173;http://localhost:5174;http://localhost:3000;http://127.0.0.1:19002" }, //配置 @@ -67,7 +67,7 @@ "DbList": [ "Sqlite", "Mysql", "Sqlserver", "Oracle", "PostgreSQL" ], "DbConnOptions": { - "Url": "server=rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com;port=3306;database=antis-foodlabeling-host;uid=javateam;pwd=javateam2026;CharSet=utf8mb4;", + "Url": "server=rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com;port=3306;database=antis-foodlabeling-host;uid=netteam;pwd=netteam;CharSet=utf8mb4;", // 高权限:仅建库/授权;租户连接串仍用 FoodLabeling:TenantDatabase 的 netteam "AdminConnectionString": "server=rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com;port=3306;database=antis-foodlabeling-host;uid=javateam;pwd=javateam2026;CharSet=utf8mb4;", "DbType": "MySql", diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-role-menu-drawer.vue b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-role-menu-drawer.vue index 22b21ee..7d961dc 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-role-menu-drawer.vue +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-role-menu-drawer.vue @@ -14,6 +14,7 @@ import { $t } from '@vben/locales'; import { Empty, Select, Tag, message } from 'ant-design-vue'; import { + thCompanyMenus, thCompanyRoles, thMenuPermissionTree, thUpdateCompanyRoleMenus, @@ -26,6 +27,7 @@ const roles = ref([]); const selectedRoleId = ref(); const menuKeys = ref([]); const menuTree = ref([]); +const companyMenuKeys = ref([]); const roleOptions = computed(() => roles.value.map((role) => ({ @@ -56,11 +58,13 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({ drawerApi.drawerLoading(true); try { - const [tree, roleList] = await Promise.all([ + const [tree, menus, roleList] = await Promise.all([ thMenuPermissionTree(), + thCompanyMenus(found.id), thCompanyRoles(found.id), ]); menuTree.value = tree; + companyMenuKeys.value = menus.menuPermissionKeys ?? []; roles.value = roleList; selectedRoleId.value = roleList[0]?.id; syncSelectedRoleMenus(); @@ -76,6 +80,7 @@ function resetState() { selectedRoleId.value = undefined; menuKeys.value = []; menuTree.value = []; + companyMenuKeys.value = []; } function syncSelectedRoleMenus() { @@ -122,6 +127,7 @@ async function handleSave() { diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue index 53e2756..9baf585 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue @@ -1,13 +1,33 @@ diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree.vue b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree.vue index 3b09a09..4b0fa7a 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree.vue +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree.vue @@ -48,7 +48,10 @@ const checkedKeys = computed({ const treeData = computed(() => { if (props.nodes.length > 0) { - return mapBackendNodes(props.nodes); + const filtered = props.allowedKeys?.length + ? filterBackendNodes(props.nodes, props.allowedKeys) + : props.nodes; + return mapBackendNodes(filtered); } const nodes = props.allowedKeys?.length diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelBatchCreateItemInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelBatchCreateItemInputVo.cs index 00df67d..6a91e82 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelBatchCreateItemInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelBatchCreateItemInputVo.cs @@ -13,6 +13,9 @@ public class LabelBatchCreateItemInputVo public List? PartnerIds { get; set; } + /// 适用 Company(单选) + public List? CompanyIds { get; set; } + public string? AppliedRegionType { get; set; } public List? RegionIds { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelCreateInputVo.cs index 759481e..2ae041e 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelCreateInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelCreateInputVo.cs @@ -9,32 +9,38 @@ public class LabelCreateInputVo public string TemplateCode { get; set; } = string.Empty; /// - /// 适用 Company(fl_partner.Id);与 合并,用于解析所属门店 + /// 适用 Company(fl_partner.Id);与 / 合并,单选 /// public string? PartnerId { get; set; } /// - /// 适用 Company 多选(fl_partner.Id) + /// 适用 Company 多选字段(仅支持 1 个;与 相同) /// public List? PartnerIds { get; set; } /// - /// 适用 Region 范围:ALL(全选)/ SPECIFIED(按 )。传了有效 regionIds 时按 SPECIFIED 处理。 + /// 适用 Company(单选,fl_partner.Id);与 / 合并 + /// + public List? CompanyIds { get; set; } + + /// + /// 适用 Region 范围:ALL / SPECIFIED。 + /// 亦可在 / 传哨兵 ["ALL"](即使本字段为 SPECIFIED 亦归档 ALL;POST/PUT 均支持)。 /// public string? AppliedRegionType { get; set; } /// - /// 适用 Region 多选(fl_group.Id);与 合并,落库 fl_label_region + /// 适用 Region 多选(fl_group.Id);与 合并;可含 ALL /// public List? RegionIds { get; set; } /// - /// 适用 Region 多选(与 相同) + /// 适用 Region 多选(与 相同);可含 ALL /// public List? GroupIds { get; set; } /// - /// 适用门店 Id 数组(location.Id,落库 fl_label_location);主字段 + /// 适用门店 Id 数组(location.Id);可含 ALL;落库 fl_label_location /// public List? LocationIds { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetOutputDto.cs index fc8d69d..8b2d0f2 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetOutputDto.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetOutputDto.cs @@ -17,11 +17,14 @@ public class LabelGetOutputDto public string LocationName { get; set; } = string.Empty; - /// 适用 Company Id(由所属门店反推) + /// 适用 Company Id(单选;优先落库 fl_label.PartnerId public string? PartnerId { get; set; } public List PartnerIds { get; set; } = new(); + /// 相同(兼容字段,单选) + public List CompanyIds { get; set; } = new(); + /// 适用 Region 范围:ALL / SPECIFIED public string AppliedRegionType { get; set; } = "SPECIFIED"; diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelUpdateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelUpdateInputVo.cs index c1904fa..f5dab1a 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelUpdateInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelUpdateInputVo.cs @@ -10,17 +10,23 @@ public class LabelUpdateInputVo public List? PartnerIds { get; set; } + /// 适用 Company(单选);与 / 合并 + public List? CompanyIds { get; set; } + /// - /// 适用 Region 范围:ALL / SPECIFIED(见 ) + /// 适用 Region 范围:ALL / SPECIFIED。 + /// 亦可在 / 传哨兵 ["ALL"](与新增一致,PUT 支持)。 /// public string? AppliedRegionType { get; set; } + /// 适用 Region 多选;可含 ALL public List? RegionIds { get; set; } + /// 相同;可含 ALL public List? GroupIds { get; set; } /// - /// 适用门店 Id 数组(location.Id,落库 fl_label_location);主字段,全量覆盖 + /// 适用门店 Id 数组;可含 ALL;落库 fl_label_location,全量覆盖 /// public List? LocationIds { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryCreateInputVo.cs index 3e630ec..06846cb 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryCreateInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryCreateInputVo.cs @@ -37,22 +37,24 @@ public class LabelCategoryCreateInputVo : ILabelEntityPartnerScopeInput public List? CompanyIds { get; set; } /// - /// 门店可用范围:ALL / SPECIFIED + /// 门店可用范围:ALL / SPECIFIED。 + /// regionIds/groupIds/locationIds 可传哨兵 ["ALL"](大小写不敏感); + /// 即使本字段为 SPECIFIED,Id 数组为 ALL 时仍归档为 ALL(POST/PUT 均支持)。 /// public string AvailabilityType { get; set; } = "ALL"; /// - /// 适用 Region(多选),fl_group.Id;与 合并去重 + /// 适用 Region(多选),fl_group.Id;与 合并去重;可含 ALL /// public List? RegionIds { get; set; } /// - /// 适用 Region(多选),与 相同 + /// 适用 Region(多选),与 相同;可含 ALL /// public List? GroupIds { get; set; } /// - /// 适用门店(多选),location.Id;与 Region 合并后写入 fl_label_category_location + /// 适用门店(多选),location.Id;可含 ALL;与 Region 合并后写入 fl_label_category_location /// public List? LocationIds { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelMultipleOption/LabelMultipleOptionCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelMultipleOption/LabelMultipleOptionCreateInputVo.cs index 7802d0d..66750f1 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelMultipleOption/LabelMultipleOptionCreateInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelMultipleOption/LabelMultipleOptionCreateInputVo.cs @@ -25,22 +25,23 @@ public class LabelMultipleOptionCreateInputVo : ILabelEntityPartnerScopeInput public List? CompanyIds { get; set; } /// - /// 门店可用范围:ALL / SPECIFIED;传了 时自动为 SPECIFIED + /// 门店可用范围:ALL / SPECIFIED;传了 时自动为 SPECIFIED。 + /// Id 数组可传哨兵 ALL(POST/PUT 新增与编辑均支持),归档为 availabilityType=ALL 且不写门店快照。 /// public string AvailabilityType { get; set; } = "ALL"; /// - /// 适用 Region(多选),fl_group.Id;与 合并去重 + /// 适用 Region(多选),fl_group.Id;与 合并去重。可传 ALL。 /// public List? RegionIds { get; set; } /// - /// 适用 Region(多选),与 相同 + /// 适用 Region(多选),与 相同。可传 ALL。 /// public List? GroupIds { get; set; } /// - /// 适用门店(多选),location.Id;与 Region 合并后写入 fl_label_multiple_option_location + /// 适用门店(多选),location.Id;与 Region 合并后写入 fl_label_multiple_option_location。可传 ALL。 /// public List? LocationIds { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelType/LabelTypeCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelType/LabelTypeCreateInputVo.cs index 131dcdc..e8127fa 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelType/LabelTypeCreateInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelType/LabelTypeCreateInputVo.cs @@ -22,22 +22,24 @@ public class LabelTypeCreateInputVo : ILabelEntityPartnerScopeInput public List? CompanyIds { get; set; } /// - /// 门店可用范围:ALL / SPECIFIED;传了 时自动为 SPECIFIED + /// 门店可用范围:ALL / SPECIFIED。 + /// regionIds/groupIds/locationIds 可传哨兵 ["ALL"](大小写不敏感); + /// 即使本字段为 SPECIFIED,Id 数组为 ALL 时仍归档为 ALL(POST/PUT 均支持)。 /// public string AvailabilityType { get; set; } = "ALL"; /// - /// 适用 Region(多选),fl_group.Id;与 合并去重 + /// 适用 Region(多选),fl_group.Id;与 合并去重;可含 ALL /// public List? RegionIds { get; set; } /// - /// 适用 Region(多选),与 相同 + /// 适用 Region(多选),与 相同;可含 ALL /// public List? GroupIds { get; set; } /// - /// 适用门店(多选),location.Id;与 Region 合并后写入 fl_label_type_location + /// 适用门店(多选),location.Id;可含 ALL;与 Region 合并后写入 fl_label_type_location /// public List? LocationIds { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs index 5f849e5..4badb3f 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; +using FoodLabeling.Application.Contracts.Json; namespace FoodLabeling.Application.Contracts.Dtos.Product; @@ -35,18 +37,33 @@ public class ProductCreateInputVo public string? AvailabilityType { get; set; } /// - /// 适用 Company(fl_partner.Id,UI 称 Company);展开该公司下全部门店后与 Region/门店合并写入 fl_location_product + /// 适用 Company(fl_partner.Id,UI 称 Company);展开该公司下全部门店后与 Region/门店合并写入 fl_location_product。 + /// 与 二选一或同值;产品 Company 仅支持单选不支持 ALL。 + /// 兼容前端误传数组:"partnerId":["{guid}"] 与字符串等价。 /// + [JsonConverter(typeof(StringOrFirstArrayItemJsonConverter))] public string? PartnerId { get; set; } /// - /// 适用 Region(fl_group.Id,UI 称 Region;库字段为 location.GroupName) + /// 适用 Company(推荐前端字段)。仅支持单选:数组最多 1 个具体 Guid;不支持 ALL。 + /// 传多个 Guid 或含 ALL 将报错;与 同时传时须一致。 + /// + public List? CompanyIds { get; set; } + + /// + /// 适用 Region(fl_group.Id,UI 称 Region;库字段为 location.GroupName)。 + /// 与 合并;含 ALL 且无具体门店时,有具体 Company 则展开该公司门店,否则归档全局 ALL。 /// public List? GroupIds { get; set; } /// - /// 适用门店 Id 列表;与 合并后写入 fl_location_product。 - /// 不传则不在本接口写入门店关联。 + /// 适用 Region(与 同义,前端常用字段)。 + /// + public List? RegionIds { get; set; } + + /// + /// 适用门店 Id 列表。有具体 Guid 时以门店为准;含 ALL 且已指定 Company/Region 时表示该范围内全选(SPECIFIED 快照); + /// 无具体 Company/Region 时归档全局 ALL。 /// public List? LocationIds { get; set; } } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs index a5da767..2d6b923 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs @@ -29,17 +29,25 @@ public class ProductGetOutputDto /// 适用门店:ALL / SPECIFIED public string AvailabilityType { get; set; } = "SPECIFIED"; - /// 适用 Company Id(fl_partner.Id,由关联门店反推;多公司时取第一个) + /// 适用 Company Id(fl_partner.Id,由关联门店反推;多公司时取第一个;ALL 时为 null) public string? PartnerId { get; set; } - /// 适用 Company Id 列表(去重) + /// 适用 Company Id 列表(去重;历史数据可能多条) public List PartnerIds { get; set; } = new(); + /// + /// 适用 Company(与编辑入参对齐)。单选:0~1 个具体 Guid;不支持 ALL。 + /// + public List CompanyIds { get; set; } = new(); + /// 适用 Region Id(fl_group.Id,由关联门店反推) public List GroupIds { get; set; } = new(); + /// 适用 Region(与 同义) + public List RegionIds { get; set; } = new(); + /// - /// 适用门店 Id 列表(来自 fl_location_product) + /// 适用门店 Id 列表(来自 fl_location_product;覆盖 Region 全集时可折叠为 ["ALL"]) /// public List LocationIds { get; set; } = new(); } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs index 77be6d3..28dc433 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs @@ -1,9 +1,11 @@ namespace FoodLabeling.Application.Contracts.Dtos.ProductCategory; +using FoodLabeling.Application.Contracts.Dtos.Common; + /// /// 产品模块:新增类别入参 /// -public class ProductCategoryCreateInputVo +public class ProductCategoryCreateInputVo : ILabelEntityPartnerScopeInput { /// /// 类别编码(可选,不传或空字符串表示无编码) @@ -30,22 +32,36 @@ public class ProductCategoryCreateInputVo public bool State { get; set; } = true; /// - /// 门店可用范围:ALL / SPECIFIED + /// 适用 Company:ALL / SPECIFIED。 + /// partnerIds/companyIds 可传哨兵 ["ALL"](大小写不敏感)。 + /// + public string? AppliedPartnerType { get; set; } + + /// 适用 Company(fl_partner.Id + public List? PartnerIds { get; set; } + + /// 相同(兼容字段) + public List? CompanyIds { get; set; } + + /// + /// 门店可用范围:ALL / SPECIFIED。 + /// regionIds/groupIds/locationIds 可传哨兵 ["ALL"](大小写不敏感); + /// 即使本字段为 SPECIFIED,Id 数组为 ALL 时仍归档为 ALL(POST/PUT 均支持)。 /// public string AvailabilityType { get; set; } = "ALL"; /// - /// 适用 Region(**多选**),fl_group.Id 数组;与 等价,推荐本字段。 + /// 适用 Region(**多选**),fl_group.Id;可含 ALL;与 合并去重。 /// public List? RegionIds { get; set; } /// - /// 适用 Region(多选),与 相同;传任一会合并去重。 + /// 适用 Region(多选),与 相同;可含 ALL。 /// public List? GroupIds { get; set; } /// - /// 适用门店(**多选**),location.Id 数组;与 Region 合并后写入 fl_product_category_location。 + /// 适用门店(**多选**),location.Id;可含 ALL;与 Region 合并后写入 fl_product_category_location。 /// public List? LocationIds { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs index ebfc336..7be3554 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs @@ -21,6 +21,16 @@ public class ProductCategoryGetListOutputDto public string AvailabilityType { get; set; } = "ALL"; + public string AppliedPartnerType { get; set; } = "ALL"; + + /// 适用 Company 展示 + public string Company { get; set; } = string.Empty; + + public List PartnerIds { get; set; } = new(); + + /// 相同 + public List CompanyIds { get; set; } = new(); + public int OrderNum { get; set; } public DateTime LastEdited { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs index 09e0891..7727aa2 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs @@ -22,6 +22,16 @@ public class ProductCategoryGetOutputDto public string AvailabilityType { get; set; } = "ALL"; + public string AppliedPartnerType { get; set; } = "ALL"; + + /// 列表/详情 Company 展示 + public string Company { get; set; } = string.Empty; + + public List PartnerIds { get; set; } = new(); + + /// 相同 + public List CompanyIds { get; set; } = new(); + /// 适用 Region Id 列表(多选,fl_group.Id;由绑定门店反推) public List RegionIds { get; set; } = new(); diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductLocation/ProductLocationGetListInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductLocation/ProductLocationGetListInputVo.cs index f0864f1..7e5ff02 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductLocation/ProductLocationGetListInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductLocation/ProductLocationGetListInputVo.cs @@ -9,6 +9,12 @@ namespace FoodLabeling.Application.Contracts.Dtos.ProductLocation; public class ProductLocationGetListInputVo : PagedAndSortedResultRequestDto { /// + /// Company Id(fl_partner.Id);未传 时按该公司下门店展开, + /// 并包含 AvailabilityType=ALL 的产品。 + /// + public string? PartnerId { get; set; } + + /// /// 门店Id(location.Id,string 表示) /// public string? LocationId { get; set; } @@ -18,4 +24,3 @@ public class ProductLocationGetListInputVo : PagedAndSortedResultRequestDto /// public string? ProductId { get; set; } } - diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs index 09f316b..398e174 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs @@ -39,7 +39,8 @@ public class TeamMemberCreateInputVo public List? GroupIds { get; set; } /// - /// 适用门店多选(location.Id);可含 ALL 哨兵,按 / 展开该公司全部门店落库。 + /// 适用门店多选(location.Id);可含 ALL 哨兵。 + /// 有具体 Region 时展开该 Region 下门店;仅有 Company 时展开该公司全部门店。 /// Company Admin 仅传 Company 时可省略。 /// public List? LocationIds { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs index 6de4648..df91908 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs @@ -38,7 +38,8 @@ public class TeamMemberUpdateInputVo public List? GroupIds { get; set; } /// - /// 适用门店多选(location.Id);可含 ALL 哨兵,按 Company 展开全部门店落库。 + /// 适用门店多选(location.Id);可含 ALL 哨兵。 + /// 有具体 Region 时表示该 Region 下全部门店;仅有 Company 时按公司全部门店落库。 /// public List? LocationIds { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAppService.cs index 7506e37..5abc6f2 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAppService.cs @@ -21,7 +21,8 @@ public interface ILabelAppService : IApplicationService Task GetAsync(string id); /// - /// 新增标签。Body 支持 appliedRegionType(ALL/SPECIFIED)、regionIds / groupIds(落库 fl_label_region)、locationIds(主字段,落库 fl_label_location);locationIdlocationIds 合并;labelTypeId 可选。 + /// 新增标签。Body 支持 appliedRegionType(ALL/SPECIFIED)、 + /// regionIds / groupIds / locationIds(可含哨兵 ALL,归档为 AppliedRegionType=ALL)。 /// Task CreateAsync(LabelCreateInputVo input); @@ -31,7 +32,7 @@ public interface ILabelAppService : IApplicationService Task BatchCreateAsync(LabelBatchCreateInputVo input); /// - /// 编辑标签(id=LabelCode)。适用 Region / 门店字段同 。 + /// 编辑标签(id=LabelCode)。范围传参与 一致,支持 Id 数组传 ALL。 /// Task UpdateAsync(string id, LabelUpdateInputVo input); diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelCategoryAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelCategoryAppService.cs index 32464df..fb18670 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelCategoryAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelCategoryAppService.cs @@ -9,8 +9,10 @@ public interface ILabelCategoryAppService Task GetAsync(string id); + /// 新增标签分类;regionIds/groupIds/locationIds 支持哨兵 ALL Task CreateAsync(LabelCategoryCreateInputVo input); + /// 编辑标签分类;范围传参与新增一致,支持 ALL Task UpdateAsync(string id, LabelCategoryUpdateInputVo input); Task DeleteAsync(string id); diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelMultipleOptionAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelMultipleOptionAppService.cs index 38919ab..36e6f99 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelMultipleOptionAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelMultipleOptionAppService.cs @@ -9,8 +9,14 @@ public interface ILabelMultipleOptionAppService Task GetAsync(string id); + /// + /// 新增多选项;regionIdsgroupIdslocationIds 可传 ALL 哨兵(POST)。 + /// Task CreateAsync(LabelMultipleOptionCreateInputVo input); + /// + /// 编辑多选项;适用范围与新增相同,regionIdsgroupIdslocationIds 可传 ALL(PUT)。 + /// Task UpdateAsync(string id, LabelMultipleOptionUpdateInputVo input); Task DeleteAsync(string id); diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTemplateAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTemplateAppService.cs index 63bfc57..1a855d1 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTemplateAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTemplateAppService.cs @@ -25,12 +25,14 @@ public interface ILabelTemplateAppService : IApplicationService /// /// 新增标签模板;body 支持 Company/Region/Location 三维范围(各维度 ALL/SPECIFIED + Id 数组)。 + /// regionIdsgroupIdslocationIdsappliedLocationIds 可传 ALL 哨兵(POST)。 /// Task CreateAsync(LabelTemplateCreateInputVo input); /// /// 编辑标签模板(版本号 +1,重建 elements);适用范围多选规则同新增。 /// body 支持 printOrientationvertical / horizontal,横打不交换 Width/Height)。 + /// regionIdsgroupIdslocationIdsappliedLocationIds 可传 ALL 哨兵(PUT)。 /// Task UpdateAsync(string id, LabelTemplateUpdateInputVo input); diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTypeAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTypeAppService.cs index aa4d03a..41d04aa 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTypeAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTypeAppService.cs @@ -9,8 +9,10 @@ public interface ILabelTypeAppService Task GetAsync(string id); + /// 新增标签类型;regionIds/groupIds/locationIds 支持哨兵 ALL Task CreateAsync(LabelTypeCreateInputVo input); + /// 编辑标签类型;范围传参与新增一致,支持 ALL Task UpdateAsync(string id, LabelTypeUpdateInputVo input); Task DeleteAsync(string id); diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs index 57f8849..6aee5eb 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs @@ -32,8 +32,10 @@ public interface IProductAppService : IApplicationService /// /// /// 可选;为空时后端生成唯一编码(如 PRD_ + Guid)。 - /// 若传 (Company)、(Region) - /// 和/或 ,合并后写入 fl_location_product。 + /// Company 传 (推荐,仅单选具体 Guid,不支持 ALL) + /// 或 ; + /// 再与 / 合并写入 fl_location_product。 + /// Region/Location 数组含 ["ALL"] 时,即使 AvailabilityType 为 SPECIFIED 也归档为 ALL,清空 fl_location_product 快照。 /// Task CreateAsync(ProductCreateInputVo input); @@ -41,8 +43,8 @@ public interface IProductAppService : IApplicationService /// 编辑产品 /// /// - /// 当请求体包含 - /// 和/或 时,合并后整表替换门店关联;均不传则不改。 + /// 当请求体包含 companyIds/partnerIdgroupIds 和/或 locationIds 时,合并后整表替换门店关联;均不传则不改。 + /// Company 仅支持单选companyIds 最多 1 个具体 Guid,不支持 ALL)。Region/Location ALL 哨兵规则同 。 /// Task UpdateAsync(Guid id, ProductUpdateInputVo input); @@ -86,7 +88,7 @@ public interface IProductAppService : IApplicationService /// { /// "items": [ /// { - /// "productName": "Tuna & Bacon Sub", + /// "productName": "Tuna & Bacon Sub", /// "categoryId": "CATEGORY_ID", /// "productCode": "40001", /// "state": true, diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs index 69e6316..752a87b 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs @@ -17,12 +17,12 @@ public interface IProductCategoryAppService : IApplicationService Task GetAsync(string id); /// - /// 新增类别;categoryCode 可选;body 传 regionIds(Region 多选)与 locationIds(门店多选)绑定适用范围。 + /// 新增类别;支持 companyIds/partnerIds 与 Region/Location 哨兵 ALL(POST)。 /// Task CreateAsync(ProductCategoryCreateInputVo input); /// - /// 编辑类别;categoryCode 可选;regionIds/locationIds 多选数组规则同新增;传空数组 [] 可清空对应范围。 + /// 编辑类别;范围传参与新增一致,companyIds 全选回显/保存支持 ["ALL"](PUT)。 /// Task UpdateAsync(string id, ProductCategoryUpdateInputVo input); diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Json/StringOrFirstArrayItemJsonConverter.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Json/StringOrFirstArrayItemJsonConverter.cs new file mode 100644 index 0000000..fce83cd --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Json/StringOrFirstArrayItemJsonConverter.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FoodLabeling.Application.Contracts.Json; + +/// +/// 反序列化时兼容 JSON 字符串或字符串数组(取首个非空元素),用于前端误把单选字段传成数组的场景(如 partnerId)。 +/// +public sealed class StringOrFirstArrayItemJsonConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + case JsonTokenType.String: + return reader.GetString(); + case JsonTokenType.StartArray: + { + string? first = null; + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + + if (reader.TokenType == JsonTokenType.Null) + { + continue; + } + + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("partnerId 数组元素必须是字符串"); + } + + var item = reader.GetString()?.Trim(); + if (first is null && !string.IsNullOrWhiteSpace(item)) + { + first = item; + } + } + + return first; + } + default: + throw new JsonException($"无法将 JSON token {reader.TokenType} 转为字符串(partnerId)"); + } + } + + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value); + } + } +} diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs index 2bd7875..f119ab7 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs @@ -1,4 +1,4 @@ -using FoodLabeling.Application.Services.DbModels; +using FoodLabeling.Application.Services.DbModels; using SqlSugar; namespace FoodLabeling.Application.Helpers; @@ -104,20 +104,51 @@ public static class AllScopeBindingHelper return LocationScopeBindingHelper.NormalizeIds(rows); } - /// 全部门店;可按 Company / Region 限定。 + /// + /// 全部门店;可按 Company / Region 限定。 + /// 同时传 Company 与 Region 时取交集(该 Region 下且属于这些 Company 的门店), + /// 禁止并集——否则回显「区内全选」无法折叠为 locationIds:["ALL"]。 + /// public static async Task> ResolveAllLocationIdsAsync( ISqlSugarClient db, IReadOnlyList? partnerIds, IReadOnlyList? regionIds) { + var partners = LocationScopeBindingHelper.NormalizeIds(partnerIds); + var regions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + + if (regions.Count > 0) + { + var fromRegions = await LocationScopeBindingHelper.ResolveLocationIdsFromGroupIdsAsync(db, regions); + if (partners.Count == 0) + { + return LocationScopeBindingHelper.NormalizeIds(fromRegions); + } + + var partnerLocSet = new HashSet( + await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners), + StringComparer.OrdinalIgnoreCase); + return LocationScopeBindingHelper.NormalizeIds( + fromRegions.Where(id => partnerLocSet.Contains(id)).ToList()); + } + + if (partners.Count > 0) + { + return await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners); + } + var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( db, - partnerIds, - regionIds, + (IReadOnlyList?)null, + null, null); return LocationScopeBindingHelper.NormalizeIds(merged); } + /// Id 数组是否含 ALL 哨兵(大小写不敏感;与具体 Guid 同传时以 ALL 为准)。 + public static bool HasAllScopeSentinelSelection(IReadOnlyList? ids) => + LocationScopeBindingHelper.ContainsAllScopeSentinel(ids); + /// Company 维度是否应存为 ALL(含「勾选了全部 Company」)。 public static async Task<(string Type, List Ids)> NormalizePartnerScopeAsync( ISqlSugarClient db, @@ -127,6 +158,10 @@ public static class AllScopeBindingHelper bool hasArrayInPayload) { var ids = LabelEntityPartnerScopeHelper.NormalizePartnerIds(partnerIds, companyIds); + if (HasAllScopeSentinelSelection(ids)) + { + return (ScopeAll, new List()); + } var allPartners = await ResolveAllPartnerIdsAsync(db); // 勾选当前全部 Company(含前端仍传 SPECIFIED + 全量 Id)→ 归档 ALL,后续新增 Company 动态适用 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allPartners); @@ -142,7 +177,12 @@ public static class AllScopeBindingHelper bool hasArrayInPayload, IReadOnlyList? partnerIdsForContext) { - var ids = LocationScopeBindingHelper.NormalizeIds(regionIds); + if (HasAllScopeSentinelSelection(regionIds)) + { + return (ScopeAll, new List()); + } + + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); var allRegions = await ResolveAllRegionIdsAsync(db, partnerIdsForContext); var isFull = ids.Count > 0 && IsFullIdSelection(ids, allRegions); var type = ResolveDimensionType(declaredType, ids, hasArrayInPayload, isFull); @@ -158,7 +198,12 @@ public static class AllScopeBindingHelper IReadOnlyList? partnerIdsForContext, IReadOnlyList? regionIdsForContext) { - var ids = LocationScopeBindingHelper.NormalizeIds(locationIds); + if (HasAllScopeSentinelSelection(locationIds)) + { + return (ScopeAll, new List()); + } + + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds); var allLocations = await ResolveAllLocationIdsAsync(db, partnerIdsForContext, regionIdsForContext); var isFull = ids.Count > 0 && IsFullIdSelection(ids, allLocations); var type = ResolveDimensionType(declaredType, ids, hasArrayInPayload, isFull); @@ -179,7 +224,7 @@ public static class AllScopeBindingHelper return new List { partnerId.Trim() }; } - var regionIds = LocationScopeBindingHelper.NormalizeIds(regionOrGroupIds); + var regionIds = LocationScopeBindingHelper.FilterConcreteScopeIds(regionOrGroupIds); if (regionIds.Count > 0) { var rows = await db.Queryable() @@ -190,7 +235,7 @@ public static class AllScopeBindingHelper return normalized.Count > 0 ? normalized : null; } - var locIds = LocationScopeBindingHelper.NormalizeIds(locationIds); + var locIds = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds); if (locIds.Count == 0) { return null; @@ -212,27 +257,55 @@ public static class AllScopeBindingHelper bool hasScopeArrays, IReadOnlyList? partnerIdsForContext) { + var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds); + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + + // 具体 Region(非全局 ALL)下即使覆盖该区全部门店,也只存 SPECIFIED 快照,不升全局 AvailabilityType=ALL + if (concreteRegions.Count > 0) + { + return false; + } + + // locationIds 含 ALL 且无具体 Region:由调用方按 Company 展开或归档全局 ALL + if (HasAllScopeSentinelSelection(locationIds)) + { + return true; + } + + if (HasAllScopeSentinelSelection(regionIds) && concreteLocations.Count == 0) + { + return true; + } + if (IsDeclaredAll(declaredAvailabilityType) - && LocationScopeBindingHelper.NormalizeIds(regionIds).Count == 0 - && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0) + && concreteRegions.Count == 0 + && concreteLocations.Count == 0) { return true; } if (hasScopeArrays - && LocationScopeBindingHelper.NormalizeIds(regionIds).Count == 0 - && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0 + && concreteRegions.Count == 0 + && concreteLocations.Count == 0 && IsDeclaredAll(declaredAvailabilityType)) { return true; } - // 前端 Select All 常传 SPECIFIED + 当前全量 Id:覆盖 Company 上下文全部门店时归档 ALL - var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( + // 前端 Select All 常传 SPECIFIED + 当前全量 Id:覆盖上下文全部门店时归档 ALL。 + // 有具体 locationIds 时:只按这些门店落 SPECIFIED 快照,禁止再升成 AvailabilityType=ALL + // (否则「Region=ALL + 选 1 店」在公司仅 1 店或误判全选时会清空快照并回显成全局 ALL)。 + if (concreteLocations.Count > 0) + { + return false; + } + + List merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( db, (IReadOnlyList?)null, - regionIds, - locationIds); + concreteRegions, + concreteLocations); + if (merged.Count == 0) { return false; @@ -241,14 +314,14 @@ public static class AllScopeBindingHelper var partnerContext = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext); if (partnerContext.Count == 0) { - partnerContext = await ResolvePartnerContextFromScopeAsync(db, null, regionIds, locationIds) + partnerContext = await ResolvePartnerContextFromScopeAsync(db, null, concreteRegions, concreteLocations) ?? new List(); } var allLocations = await ResolveAllLocationIdsAsync( db, partnerContext.Count > 0 ? partnerContext : null, - null); + concreteRegions.Count > 0 ? concreteRegions : null); return IsFullIdSelection(merged, allLocations); } @@ -261,10 +334,35 @@ public static class AllScopeBindingHelper IReadOnlyList? locationIds, bool hasScopePayload) { + // partnerId 字符串为 ALL 哨兵:全选 Company,归档 ALL(禁止把 ALL 当 Guid 查库) + if (LocationScopeBindingHelper.IsAllScopeSentinel(partnerId)) + { + return true; + } + + var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds); + var concreteGroups = LocationScopeBindingHelper.FilterConcreteScopeIds(groupIds); + + // 具体 Region 下不升全局 AvailabilityType=ALL(与 ShouldTreatMergedLocationScopeAsAllAsync 一致) + if (concreteGroups.Count > 0) + { + return false; + } + + if (HasAllScopeSentinelSelection(locationIds)) + { + return true; + } + + if (HasAllScopeSentinelSelection(groupIds) && concreteLocations.Count == 0) + { + return true; + } + if (IsDeclaredAll(declaredAvailabilityType) && string.IsNullOrWhiteSpace(partnerId) - && LocationScopeBindingHelper.NormalizeIds(groupIds).Count == 0 - && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0) + && concreteGroups.Count == 0 + && concreteLocations.Count == 0) { return true; } @@ -274,20 +372,29 @@ public static class AllScopeBindingHelper return false; } + // 有具体门店时不升全局 ALL + if (concreteLocations.Count > 0) + { + return false; + } + var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( db, partnerId, - groupIds, - locationIds); + concreteGroups, + concreteLocations); + if (merged.Count == 0) { return false; } - // 未传 partnerId 时从 Region/门店反推 Company,避免用「全系统门店」误判导致无法归档 ALL var partnerContext = await ResolvePartnerContextFromScopeAsync( - db, partnerId, groupIds, locationIds); - var allLocations = await ResolveAllLocationIdsAsync(db, partnerContext, null); + db, partnerId, concreteGroups, concreteLocations); + var allLocations = await ResolveAllLocationIdsAsync( + db, + partnerContext, + concreteGroups.Count > 0 ? concreteGroups : null); return IsFullIdSelection(merged, allLocations); } @@ -319,4 +426,167 @@ public static class AllScopeBindingHelper : await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, locs); return (partnerIds, regionIds, locs); } + + /// + /// 标签/产品分类等:解析 Region + Location 落库(对齐 Label:Region=ALL 时可保留具体门店快照)。 + /// + public sealed class LabelEntityRegionLocationSaveResult + { + public string AvailabilityType { get; init; } = ScopeSpecified; + + public string AppliedRegionType { get; init; } = ScopeAll; + + public List LocationIds { get; init; } = new(); + } + + public static async Task ResolveLabelEntityRegionLocationForSaveAsync( + ISqlSugarClient db, + string? declaredAvailabilityType, + IReadOnlyList? partnerIdsForContext, + IReadOnlyList? mergedRegionIdsRaw, + IReadOnlyList? locationIdsRaw, + bool hasScopeArrays) + { + var normalizedLocationIds = LocationScopeBindingHelper.NormalizeIds(locationIdsRaw); + var mergedRegionIds = LocationScopeBindingHelper.NormalizeIds(mergedRegionIdsRaw); + var regionIds = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedRegionIds); + var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(normalizedLocationIds); + var locationHasAll = HasAllScopeSentinelSelection(normalizedLocationIds); + var regionHasAllSentinel = HasAllScopeSentinelSelection(mergedRegionIds); + var hasConcretePartner = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext).Count > 0; + + // 前端 Select All Region 常展开为全量 Guid,需识别为 Region=ALL + var regionHasAll = regionHasAllSentinel; + if (!regionHasAll && regionIds.Count > 0) + { + var allRegions = await ResolveAllRegionIdsAsync( + db, + hasConcretePartner ? partnerIdsForContext : null); + regionHasAll = allRegions.Count > 0 && IsFullIdSelection(regionIds, allRegions); + } + + // location ALL 哨兵,或具体 Region 下「空选/全选门店」:有 Company/Region 上下文则展开 SPECIFIED + var locationCoversConcreteRegions = false; + if (!locationHasAll && regionIds.Count > 0 && !regionHasAll) + { + if (explicitLocationIds.Count == 0) + { + locationCoversConcreteRegions = true; + } + else + { + var regionUniverse = await ResolveAllLocationIdsAsync( + db, + hasConcretePartner ? partnerIdsForContext : null, + regionIds); + locationCoversConcreteRegions = regionUniverse.Count > 0 + && IsFullIdSelection(explicitLocationIds, regionUniverse); + } + } + + if (locationHasAll || locationCoversConcreteRegions) + { + var expandRegions = regionHasAll ? null : (regionIds.Count > 0 ? regionIds : null); + var expanded = await LocationScopeBindingHelper.ExpandScopedAllLocationsForSaveAsync( + db, + hasConcretePartner ? partnerIdsForContext : null, + expandRegions); + if (expanded is not null) + { + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeSpecified, + AppliedRegionType = regionHasAll || regionIds.Count == 0 ? ScopeAll : ScopeSpecified, + LocationIds = expanded + }; + } + + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeAll, + AppliedRegionType = ScopeAll, + LocationIds = new List() + }; + } + + // Region=ALL + 具体门店:Region 存 ALL,门店 SPECIFIED 快照(可回显 regionIds=["ALL"] + 单个 locationId) + if (regionHasAll && explicitLocationIds.Count > 0) + { + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds); + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeSpecified, + AppliedRegionType = ScopeAll, + LocationIds = explicitLocationIds + }; + } + + // Region=ALL 且无具体门店 → 双 ALL + if (regionHasAll) + { + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeAll, + AppliedRegionType = ScopeAll, + LocationIds = new List() + }; + } + + // 有具体门店(可同传具体 Region):以门店为准 + if (explicitLocationIds.Count > 0) + { + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds); + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeSpecified, + AppliedRegionType = ScopeSpecified, + LocationIds = explicitLocationIds + }; + } + + if (await ShouldTreatMergedLocationScopeAsAllAsync( + db, + declaredAvailabilityType, + regionIds, + explicitLocationIds, + hasScopeArrays, + partnerIdsForContext)) + { + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = ScopeAll, + AppliedRegionType = ScopeAll, + LocationIds = new List() + }; + } + + var availabilityType = (declaredAvailabilityType ?? ScopeAll).Trim().ToUpperInvariant(); + if (regionIds.Count > 0) + { + availabilityType = ScopeSpecified; + } + else if (hasScopeArrays && IsDeclaredAll(availabilityType)) + { + availabilityType = ScopeAll; + } + + if (availabilityType != ScopeAll && availabilityType != ScopeSpecified) + { + throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); + } + + var locationSpecified = string.Equals(availabilityType, ScopeSpecified, StringComparison.OrdinalIgnoreCase); + var savedLocationIds = await LocationScopeBindingHelper.ResolveEntityLocationIdsForSaveAsync( + db, + locationSpecified, + regionIds, + explicitLocationIds); + + return new LabelEntityRegionLocationSaveResult + { + AvailabilityType = locationSpecified ? ScopeSpecified : ScopeAll, + AppliedRegionType = locationSpecified ? ScopeSpecified : ScopeAll, + LocationIds = savedLocationIds + }; + } } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/EntityLocationScopeDisplayHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/EntityLocationScopeDisplayHelper.cs index c53fee6..92fb678 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/EntityLocationScopeDisplayHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/EntityLocationScopeDisplayHelper.cs @@ -11,6 +11,10 @@ public static class EntityLocationScopeDisplayHelper /// /// 根据可用范围类型与已解析的 Region/Location Id、名称,生成列表展示文案。 /// + /// + /// 有具体门店 Guid 时始终展示门店名称,不因「覆盖某 Region 下全部门店」误显示 All Location + /// (常见于 AppliedRegionType=ALL + 单个 locationIds)。 + /// public static async Task<(string Region, string Location)> BuildListDisplayAsync( ISqlSugarClient db, string? availabilityType, @@ -18,7 +22,8 @@ public static class EntityLocationScopeDisplayHelper IReadOnlyList locationIds, IEnumerable regionNames, IEnumerable locationNames, - IReadOnlyList? partnerIdsForContext) + IReadOnlyList? partnerIdsForContext, + string? appliedRegionType = null) { if (AllScopeBindingHelper.IsDeclaredAll(availabilityType)) { @@ -29,27 +34,33 @@ public static class EntityLocationScopeDisplayHelper var normLocationIds = LocationScopeBindingHelper.NormalizeIds(locationIds); var partnerContext = partnerIdsForContext is { Count: > 0 } ? partnerIdsForContext : null; - var allRegionIds = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, partnerContext); - var regionIsAll = normRegionIds.Count > 0 - && allRegionIds.Count > 0 - && AllScopeBindingHelper.IsFullIdSelection(normRegionIds, allRegionIds); - - var regionContextForLocations = regionIsAll ? null : normRegionIds.Count > 0 ? normRegionIds : null; - var allLocationIds = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( - db, - partnerContext, - regionContextForLocations); - var locationIsAll = normLocationIds.Count > 0 - && allLocationIds.Count > 0 - && AllScopeBindingHelper.IsFullIdSelection(normLocationIds, allLocationIds); - - var regionDisplay = regionIsAll - ? AllScopeBindingHelper.AllRegionsDisplay - : JoinDistinctNames(regionNames); + string regionDisplay; + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType) + || AllScopeBindingHelper.HasAllScopeSentinelSelection(normRegionIds)) + { + regionDisplay = AllScopeBindingHelper.AllRegionsDisplay; + } + else + { + var allRegionIds = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, partnerContext); + var regionIsAll = normRegionIds.Count > 0 + && allRegionIds.Count > 0 + && AllScopeBindingHelper.IsFullIdSelection(normRegionIds, allRegionIds); + regionDisplay = regionIsAll + ? AllScopeBindingHelper.AllRegionsDisplay + : JoinDistinctNames(regionNames); + } - var locationDisplay = locationIsAll - ? AllScopeBindingHelper.AllLocationsDisplay - : JoinDistinctNames(locationNames); + // 具体门店 Guid → 显示门店名;仅 AvailabilityType=ALL 或 locationIds 含 ALL 哨兵才显示 All Location + string locationDisplay; + if (AllScopeBindingHelper.HasAllScopeSentinelSelection(normLocationIds)) + { + locationDisplay = AllScopeBindingHelper.AllLocationsDisplay; + } + else + { + locationDisplay = JoinDistinctNames(locationNames); + } return (regionDisplay, locationDisplay); } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityListScopeHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityListScopeHelper.cs index d8c8296..5b75c4a 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityListScopeHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityListScopeHelper.cs @@ -4,12 +4,37 @@ using SqlSugar; namespace FoodLabeling.Application.Helpers; /// -/// 标签类型/分类/多选项列表:非平台管理员仅可见其绑定 Company 范围内数据。 +/// 标签类型/分类/多选项列表:按可见门店范围过滤。 +/// AvailabilityType=ALL 时传任意 Region/Location 筛选均命中。 /// public static class LabelEntityListScopeHelper { /// - /// 标签类型列表:按 Company + Region/Location 范围过滤(Company Admin 不可见 All Companies 数据)。 + /// 是否应用门店 Availability 过滤。 + /// 仅传 partnerId(未传 groupId/locationId)时返回 false:只按 Company 维度筛, + /// 避免「多公司绑定」在第二家公司暂无门店或门店未落入关联表时被误杀。 + /// + public static bool ShouldApplyLocationAvailabilityFilter( + string? partnerId, + string? groupId, + string? locationId) + { + if (!string.IsNullOrWhiteSpace(groupId) || !string.IsNullOrWhiteSpace(locationId)) + { + return true; + } + + // 仅 PartnerId:Company 过滤由 Apply*PartnerListFilter 负责 + if (!string.IsNullOrWhiteSpace(partnerId)) + { + return false; + } + + return true; + } + + /// + /// 标签类型列表:门店可用范围过滤(ALL 命中任意门店筛选)。 /// public static ISugarQueryable ApplyTypeLocationAvailabilityFilter( ISugarQueryable query, @@ -22,12 +47,12 @@ public static class LabelEntityListScopeHelper if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + // 无可见门店时仍保留 AvailabilityType=ALL(仅 Company 筛选时公司可能暂无门店) + return query.Where(t => t.AvailabilityType == AllScopeBindingHelper.ScopeAll); } return query.Where(t => - (t.AvailabilityType == AllScopeBindingHelper.ScopeAll - && t.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified) + t.AvailabilityType == AllScopeBindingHelper.ScopeAll || (t.AvailabilityType == AllScopeBindingHelper.ScopeSpecified && SqlFunc.Subqueryable() .Where(tl => tl.LabelTypeId == t.Id && scopedLocationIds.Contains(tl.LocationId)) @@ -35,7 +60,7 @@ public static class LabelEntityListScopeHelper } /// - /// 标签分类列表:按 Company + Region/Location 范围过滤。 + /// 标签分类列表:门店可用范围过滤(ALL 命中任意门店筛选)。 /// public static ISugarQueryable ApplyCategoryLocationAvailabilityFilter( ISugarQueryable query, @@ -48,12 +73,11 @@ public static class LabelEntityListScopeHelper if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + return query.Where(c => c.AvailabilityType == AllScopeBindingHelper.ScopeAll); } return query.Where(c => - (c.AvailabilityType == AllScopeBindingHelper.ScopeAll - && c.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified) + c.AvailabilityType == AllScopeBindingHelper.ScopeAll || (c.AvailabilityType == AllScopeBindingHelper.ScopeSpecified && SqlFunc.Subqueryable() .Where(cl => cl.CategoryId == c.Id && scopedLocationIds.Contains(cl.LocationId)) @@ -61,7 +85,7 @@ public static class LabelEntityListScopeHelper } /// - /// 标签多选项列表:按 Company + Region/Location 范围过滤。 + /// 标签多选项列表:门店可用范围过滤(ALL 命中任意门店筛选)。 /// public static ISugarQueryable ApplyMultipleOptionLocationAvailabilityFilter( ISugarQueryable query, @@ -74,12 +98,11 @@ public static class LabelEntityListScopeHelper if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + return query.Where(o => o.AvailabilityType == AllScopeBindingHelper.ScopeAll); } return query.Where(o => - (o.AvailabilityType == AllScopeBindingHelper.ScopeAll - && o.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified) + o.AvailabilityType == AllScopeBindingHelper.ScopeAll || (o.AvailabilityType == AllScopeBindingHelper.ScopeSpecified && SqlFunc.Subqueryable() .Where(ol => ol.MultipleOptionId == o.Id && scopedLocationIds.Contains(ol.LocationId)) diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityPartnerScopeHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityPartnerScopeHelper.cs index b335669..22aebfe 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityPartnerScopeHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityPartnerScopeHelper.cs @@ -26,7 +26,10 @@ public static class LabelEntityPartnerScopeHelper Category, /// 标签多选项 fl_label_multiple_option - MultipleOption + MultipleOption, + + /// 产品分类 fl_product_category + ProductCategory } public sealed class LabelEntityPartnerScopeSaveResult @@ -60,6 +63,10 @@ public static class LabelEntityPartnerScopeHelper public bool HasCategoryPartnerTable { get; init; } public bool HasMultipleOptionPartnerTable { get; init; } + + public bool HasProductCategoryPartnerColumn { get; init; } + + public bool HasProductCategoryPartnerTable { get; init; } } /// @@ -185,7 +192,7 @@ public static class LabelEntityPartnerScopeHelper if (needsPartnerRows && !hasTable) { throw new UserFriendlyException( - "Company 适用范围关联表尚未就绪,请联系管理员执行数据库迁移(fl_label_entity_partner_scope.sql)后重试"); + "Company 适用范围关联表尚未就绪,请联系管理员执行数据库迁移(fl_label_entity_partner_scope.sql 或 fl_product_category_partner_scope.sql)后重试"); } if (hasTable) @@ -325,11 +332,23 @@ public static class LabelEntityPartnerScopeHelper ? AllCompaniesDisplay : BuildCompanyDisplay(pIds, partnerNameById); + List displayPartnerIds; + if (string.Equals(partnerType, ScopeAll, StringComparison.OrdinalIgnoreCase)) + { + displayPartnerIds = new List { AllScopeBindingHelper.ScopeAll }; + } + else + { + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, pIds, null, null); + displayPartnerIds = collapsed.PartnerIds; + } + result[entityId] = new LabelEntityPartnerScopeDisplay { Company = companyDisplay, AppliedPartnerType = partnerType, - PartnerIds = pIds + PartnerIds = displayPartnerIds }; } @@ -383,11 +402,13 @@ public static class LabelEntityPartnerScopeHelper return query.Where(_ => false); } + // Company=ALL:传任意 partnerId 均命中;SPECIFIED:须与关联表有交集 return query.Where(t => - t.AppliedPartnerType == ScopeSpecified - && SqlFunc.Subqueryable() - .Where(p => p.LabelTypeId == t.Id && scopedPartnerIds.Contains(p.PartnerId)) - .Any()); + t.AppliedPartnerType == ScopeAll + || (t.AppliedPartnerType == ScopeSpecified + && SqlFunc.Subqueryable() + .Where(p => p.LabelTypeId == t.Id && scopedPartnerIds.Contains(p.PartnerId)) + .Any())); } /// @@ -415,10 +436,11 @@ public static class LabelEntityPartnerScopeHelper } return query.Where(c => - c.AppliedPartnerType == ScopeSpecified - && SqlFunc.Subqueryable() - .Where(p => p.CategoryId == c.Id && scopedPartnerIds.Contains(p.PartnerId)) - .Any()); + c.AppliedPartnerType == ScopeAll + || (c.AppliedPartnerType == ScopeSpecified + && SqlFunc.Subqueryable() + .Where(p => p.CategoryId == c.Id && scopedPartnerIds.Contains(p.PartnerId)) + .Any())); } /// @@ -446,14 +468,48 @@ public static class LabelEntityPartnerScopeHelper } return query.Where(o => - o.AppliedPartnerType == ScopeSpecified - && SqlFunc.Subqueryable() - .Where(p => p.MultipleOptionId == o.Id && scopedPartnerIds.Contains(p.PartnerId)) - .Any()); + o.AppliedPartnerType == ScopeAll + || (o.AppliedPartnerType == ScopeSpecified + && SqlFunc.Subqueryable() + .Where(p => p.MultipleOptionId == o.Id && scopedPartnerIds.Contains(p.PartnerId)) + .Any())); + } + + /// + /// 列表筛选:按可见 Company 过滤产品分类。 + /// + public static async Task> ApplyProductCategoryPartnerListFilterAsync( + ISqlSugarClient db, + ISugarQueryable query, + IReadOnlyList? scopedPartnerIds) + { + if (scopedPartnerIds is null) + { + return query; + } + + var schema = await GetSchemaStatusAsync(db); + if (!HasPartnerTableForKind(schema, LabelEntityPartnerKind.ProductCategory)) + { + return query; + } + + if (scopedPartnerIds.Count == 0) + { + return query.Where(_ => false); + } + + return query.Where(c => + c.AppliedPartnerType == ScopeAll + || (c.AppliedPartnerType == ScopeSpecified + && SqlFunc.Subqueryable() + .Where(p => p.CategoryId == c.Id && scopedPartnerIds.Contains(p.PartnerId)) + .Any())); } /// /// 由 Query partnerId 解析列表 Company 筛选 Id;未传则返回 null。 + /// Id 统一为小写 Guid,避免与关联表大小写不一致导致 IN 匹配失败。 /// public static async Task?> ResolveScopedPartnerIdsForListAsync( ISqlSugarClient db, @@ -467,7 +523,13 @@ public static class LabelEntityPartnerScopeHelper var exists = await db.Queryable() .AnyAsync(x => !x.IsDeleted && x.Id == pid); - return exists ? new List { pid } : new List(); + if (!exists) + { + return new List(); + } + + var key = TeamMemberListScopeHelper.NormalizeScopeKey(pid); + return string.IsNullOrEmpty(key) ? new List { pid } : new List { key }; } /// @@ -514,6 +576,11 @@ public static class LabelEntityPartnerScopeHelper .Where(x => x.MultipleOptionId == entityId) .ExecuteCommandAsync(); break; + case LabelEntityPartnerKind.ProductCategory: + await db.Deleteable() + .Where(x => x.CategoryId == entityId) + .ExecuteCommandAsync(); + break; } } @@ -564,7 +631,10 @@ public static class LabelEntityPartnerScopeHelper db, "fl_label_multiple_option", "AppliedPartnerType"), HasTypePartnerTable = await TableExistsAsync(db, "fl_label_type_partner"), HasCategoryPartnerTable = await TableExistsAsync(db, "fl_label_category_partner"), - HasMultipleOptionPartnerTable = await TableExistsAsync(db, "fl_label_multiple_option_partner") + HasMultipleOptionPartnerTable = await TableExistsAsync(db, "fl_label_multiple_option_partner"), + HasProductCategoryPartnerColumn = await ColumnExistsAsync( + db, "fl_product_category", "AppliedPartnerType"), + HasProductCategoryPartnerTable = await TableExistsAsync(db, "fl_product_category_partner") }; } catch @@ -581,6 +651,7 @@ public static class LabelEntityPartnerScopeHelper LabelEntityPartnerKind.Type => schema.HasTypePartnerTable, LabelEntityPartnerKind.Category => schema.HasCategoryPartnerTable, LabelEntityPartnerKind.MultipleOption => schema.HasMultipleOptionPartnerTable, + LabelEntityPartnerKind.ProductCategory => schema.HasProductCategoryPartnerTable, _ => false }; @@ -590,6 +661,7 @@ public static class LabelEntityPartnerScopeHelper LabelEntityPartnerKind.Type => schema.HasTypePartnerColumn, LabelEntityPartnerKind.Category => schema.HasCategoryPartnerColumn, LabelEntityPartnerKind.MultipleOption => schema.HasMultipleOptionPartnerColumn, + LabelEntityPartnerKind.ProductCategory => schema.HasProductCategoryPartnerColumn, _ => false }; @@ -637,6 +709,7 @@ public static class LabelEntityPartnerScopeHelper LabelEntityPartnerKind.Type => "fl_label_type", LabelEntityPartnerKind.Category => "fl_label_category", LabelEntityPartnerKind.MultipleOption => "fl_label_multiple_option", + LabelEntityPartnerKind.ProductCategory => "fl_product_category", _ => string.Empty }; @@ -666,6 +739,7 @@ public static class LabelEntityPartnerScopeHelper LabelEntityPartnerKind.Type => "fl_label_type", LabelEntityPartnerKind.Category => "fl_label_category", LabelEntityPartnerKind.MultipleOption => "fl_label_multiple_option", + LabelEntityPartnerKind.ProductCategory => "fl_product_category", _ => string.Empty }; @@ -709,6 +783,13 @@ public static class LabelEntityPartnerScopeHelper .ToListAsync(); return rows.Select(x => (x.MultipleOptionId, x.PartnerId)).ToList(); } + case LabelEntityPartnerKind.ProductCategory: + { + var rows = await db.Queryable() + .Where(x => entityIds.Contains(x.CategoryId)) + .ToListAsync(); + return rows.Select(x => (x.CategoryId, x.PartnerId)).ToList(); + } default: return new List<(string EntityId, string PartnerId)>(); } @@ -764,6 +845,19 @@ public static class LabelEntityPartnerScopeHelper await db.Insertable(rows).ExecuteCommandAsync(); break; } + case LabelEntityPartnerKind.ProductCategory: + { + var rows = partnerIds.Select(pid => new FlProductCategoryPartnerDbEntity + { + Id = guidGenerator.Create().ToString(), + CategoryId = entityId, + PartnerId = pid, + CreationTime = now, + CreatorId = currentUserId + }).ToList(); + await db.Insertable(rows).ExecuteCommandAsync(); + break; + } } } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelQueryHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelQueryHelper.cs index d7f7ce9..b3bc4f3 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelQueryHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelQueryHelper.cs @@ -28,6 +28,7 @@ public static class LabelQueryHelper LabelName = x.LabelName, TemplateId = x.TemplateId, LocationId = x.LocationId, + PartnerId = x.PartnerId, LabelCategoryId = x.LabelCategoryId, LabelTypeId = x.LabelTypeId, LabelType = x.LabelType, diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelRegionScopeHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelRegionScopeHelper.cs index 09fd63b..d38cc65 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelRegionScopeHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelRegionScopeHelper.cs @@ -58,12 +58,40 @@ public static class LabelRegionScopeHelper IReadOnlyList? regionIds, IReadOnlyList? groupIds, string? locationId, - IReadOnlyList? locationIds) + IReadOnlyList? locationIds, + IReadOnlyList? partnerIdsForContext = null) { - var mergedRegionIds = NormalizeRegionIds(regionIds, groupIds); - var explicitLocationIds = MergeExplicitLocationIds(locationId, locationIds); + var mergedRegionIdsRaw = NormalizeRegionIds(regionIds, groupIds); + var explicitLocationIdsRaw = MergeExplicitLocationIds(locationId, locationIds); + var regionHasAll = LocationScopeBindingHelper.ContainsAllScopeSentinel(mergedRegionIdsRaw); + var locationHasAll = LocationScopeBindingHelper.ContainsAllScopeSentinel(explicitLocationIdsRaw); + var mergedRegionIds = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedRegionIdsRaw); + var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(explicitLocationIdsRaw); var type = (appliedRegionType ?? AppliedRegionAll).Trim().ToUpperInvariant(); var hasScopeArrays = regionIds is not null || groupIds is not null || locationIds is not null; + var partnerContext = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext); + + // locationIds / regionIds 含 ALL 哨兵:不归档为 Guid 校验,按全选处理(Company 由 fl_label.PartnerId 落库) + if (locationHasAll || (regionHasAll && explicitLocationIds.Count == 0)) + { + return new LabelRegionScopeSaveResult + { + AppliedRegionType = AppliedRegionAll, + RegionIds = new List(), + LocationIds = new List() + }; + } + + if (regionHasAll && explicitLocationIds.Count > 0) + { + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds); + return new LabelRegionScopeSaveResult + { + AppliedRegionType = AppliedRegionAll, + RegionIds = new List(), + LocationIds = explicitLocationIds + }; + } if (mergedRegionIds.Count > 0) { @@ -95,8 +123,10 @@ public static class LabelRegionScopeHelper await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds); } - var partnerContext = await ResolvePartnerContextAsync(db, mergedRegionIds, explicitLocationIds); - var allRegions = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, partnerContext); + var resolvedPartnerContext = partnerContext.Count > 0 + ? partnerContext + : await ResolvePartnerContextAsync(db, mergedRegionIds, explicitLocationIds); + var allRegions = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, resolvedPartnerContext); var regionsFull = mergedRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(mergedRegionIds, allRegions); @@ -107,7 +137,7 @@ public static class LabelRegionScopeHelper if (declaredOrFullAll) { var allLocationsForPartner = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( - db, partnerContext, null); + db, resolvedPartnerContext, null); var locationsFullForPartner = explicitLocationIds.Count == 0 || (allLocationsForPartner.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocationsForPartner)); @@ -135,7 +165,7 @@ public static class LabelRegionScopeHelper if (mergedRegionIds.Count == 0 && explicitLocationIds.Count > 0) { var allLocations = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( - db, partnerContext, null); + db, resolvedPartnerContext, null); if (allLocations.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocations)) { @@ -157,7 +187,7 @@ public static class LabelRegionScopeHelper // 部分 Region:门店 Select All(空或覆盖该区域全集)→ 只落 Region,不写门店快照(新区店靠 Region 动态匹配) var allLocationsInRegions = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( - db, null, mergedRegionIds); + db, resolvedPartnerContext, mergedRegionIds); var locationsFullInRegions = explicitLocationIds.Count == 0 || (allLocationsInRegions.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocationsInRegions)); @@ -179,7 +209,7 @@ public static class LabelRegionScopeHelper // 部分 Region + 部分门店 → 校验门店属于所选 Region 后落快照 var mergedLocations = await LocationScopeBindingHelper.MergeToLocationIdsAsync( - db, (IReadOnlyList?)null, mergedRegionIds, explicitLocationIds); + db, resolvedPartnerContext, mergedRegionIds, explicitLocationIds); if (mergedLocations.Count == 0) { throw new UserFriendlyException("指定 Region 下未匹配到有效门店,请检查 Region 或 locationIds"); @@ -405,8 +435,8 @@ public static class LabelRegionScopeHelper return new LabelRegionScopeDisplay { Region = AllRegionsDisplay, - RegionIds = new List(), - GroupIds = new List() + RegionIds = new List { AllScopeBindingHelper.ScopeAll }, + GroupIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -435,11 +465,18 @@ public static class LabelRegionScopeHelper ? string.Join(", ", names.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!.Trim()).Distinct()) : "?"; + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, + null, + regionIds, + locationIds, + ScopeAllEchoHelper.ForLabelRegionScope(appliedRegionType, locationIds)); + return new LabelRegionScopeDisplay { Region = regionText, - RegionIds = regionIds, - GroupIds = regionIds + RegionIds = collapsed.RegionIds, + GroupIds = collapsed.RegionIds }; } @@ -454,7 +491,7 @@ public static class LabelRegionScopeHelper return new LabelLocationScopeDisplay { Location = AllLocationsDisplay, - LocationIds = new List() + LocationIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -482,10 +519,17 @@ public static class LabelRegionScopeHelper ? string.Join(", ", names.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!.Trim()).Distinct()) : "?"; + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, + null, + null, + locationIds, + ScopeAllEchoHelper.ForLabelRegionScope(appliedRegionType, locationIds)); + return new LabelLocationScopeDisplay { Location = locationText, - LocationIds = locationIds.ToList() + LocationIds = collapsed.LocationIds }; } @@ -593,7 +637,24 @@ public static class LabelRegionScopeHelper new { all = AppliedRegionAll, gid }); } - /// ? scoped ?????fl_label_location ???? LocationId?? + /// + /// 无可见门店时:仅保留 AppliedRegionType=ALL 的标签(用于 PartnerId 筛选但该公司暂无门店)。 + /// + public static ISugarQueryable ApplyLabelAllRegionOnlyFilter( + ISqlSugarClient db, + ISugarQueryable query, + LabelRegionSchemaHelper.LabelRegionSchemaStatus schema) + { + if (!schema.HasAppliedRegionTypeColumn) + { + // 未迁移列时无法识别 ALL,保守返回空 + return query.Where(_ => false); + } + + return query.Where("AppliedRegionType = @all", new { all = AppliedRegionAll }); + } + + /// 按 scoped 门店过滤;AppliedRegionType=ALL 对任意门店筛选均命中。 public static ISugarQueryable ApplyLabelLocationListFilter( ISqlSugarClient db, ISugarQueryable query, @@ -602,7 +663,7 @@ public static class LabelRegionScopeHelper { if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + return ApplyLabelAllRegionOnlyFilter(db, query, schema); } return ApplyLabelLocationMatchFilter(query, scopedLocationIds, schema); @@ -648,6 +709,14 @@ public static class LabelRegionScopeHelper var locationIds = await GetLocationIdsForLabelAsync(db, label.Id, label.LocationId); if (locationIds.Count == 0) { + // 指定 Company 的 ALL:仅该公司下门店可用 + if (!string.IsNullOrWhiteSpace(label.PartnerId)) + { + var locPartners = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( + db, new List { locationId.Trim() }); + return locPartners.Contains(label.PartnerId.Trim(), StringComparer.OrdinalIgnoreCase); + } + return true; } @@ -784,6 +853,26 @@ public static class LabelRegionScopeHelper List scopedLocationIds, LabelRegionSchemaHelper.LabelRegionSchemaStatus schema) { + if (schema.HasAppliedRegionTypeColumn && schema.HasLabelLocationTable) + { + return query.Where( + """ + (AppliedRegionType = @all + OR LocationId IN (@locs) + OR EXISTS (SELECT 1 FROM fl_label_location ll WHERE ll.LabelId = fl_label.Id AND ll.LocationId IN (@locs))) + """, + new { all = AppliedRegionAll, locs = scopedLocationIds }); + } + + if (schema.HasAppliedRegionTypeColumn) + { + return query.Where( + """ + (AppliedRegionType = @all OR LocationId IN (@locs)) + """, + new { all = AppliedRegionAll, locs = scopedLocationIds }); + } + if (schema.HasLabelLocationTable) { return query.Where( @@ -829,17 +918,18 @@ public static class LabelRegionScopeHelper private static async Task ValidateRegionIdsExistAsync(ISqlSugarClient db, List regionIds) { - if (regionIds.Count == 0) + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + if (ids.Count == 0) { return; } var count = await db.Queryable() - .Where(g => !g.IsDeleted && regionIds.Contains(g.Id)) + .Where(g => !g.IsDeleted && ids.Contains(g.Id)) .CountAsync(); - if (count != regionIds.Count) + if (count != ids.Count) { - throw new UserFriendlyException("????? Region Id???????"); + throw new UserFriendlyException("存在无效的 Region Id,请刷新后重试"); } } } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs index a40d117..7936c46 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs @@ -1,4 +1,4 @@ -using FoodLabeling.Application.Contracts.Dtos.LabelTemplate; +using FoodLabeling.Application.Contracts.Dtos.LabelTemplate; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Domain.Entities; using SqlSugar; @@ -58,13 +58,22 @@ public static class LabelTemplateScopeHelper /// /// 解析新增/编辑入参中的 Company / Region / Location 范围。 + /// Create/Update 共用;regionIdsgroupIdslocationIdsappliedLocationIds 可传哨兵 ALL, + /// 即使 appliedRegionType / appliedLocationSPECIFIED 也会归档为对应维度 ALL 且不写关联快照。 /// public static async Task ResolveScopeForSaveAsync( ISqlSugarClient db, LabelTemplateCreateInputVo input) { - var regionIds = NormalizeRegionIds(input); - var locationIds = MergeExplicitLocationIds(input); + var mergedRegionIds = NormalizeRegionIds(input); + var mergedLocationIds = MergeExplicitLocationIds(input); + var hasRegionArray = input.RegionIds is not null || input.GroupIds is not null; + var hasLocationArray = input.LocationIds is not null || input.AppliedLocationIds is not null; + + // Create/Update 共用:优先识别 ALL 哨兵,避免后续 Count>0 或 Guid 存在性校验误伤编辑回显的 ["ALL"] + var locationHasAll = AllScopeBindingHelper.HasAllScopeSentinelSelection(mergedLocationIds); + var regionHasAll = AllScopeBindingHelper.HasAllScopeSentinelSelection(mergedRegionIds); + var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedLocationIds); var (partnerType, partnerIdsForSave) = await AllScopeBindingHelper.NormalizePartnerScopeAsync( db, @@ -78,22 +87,44 @@ public static class LabelTemplateScopeHelper ? partnerIds : null; - var (regionType, regionIdsForSave) = await AllScopeBindingHelper.NormalizeRegionScopeAsync( - db, - input.AppliedRegionType, - regionIds, - input.RegionIds is not null || input.GroupIds is not null, - partnerContext); - regionIds = regionIdsForSave; + string regionType; + List regionIds; + if (regionHasAll && concreteLocations.Count == 0) + { + regionType = ScopeAll; + regionIds = new List(); + } + else + { + var regionResult = await AllScopeBindingHelper.NormalizeRegionScopeAsync( + db, + input.AppliedRegionType, + LocationScopeBindingHelper.FilterConcreteScopeIds(mergedRegionIds), + hasRegionArray, + partnerContext); + regionType = regionResult.Type; + regionIds = regionResult.Ids; + } - var (locationType, locationIdsForSave) = await AllScopeBindingHelper.NormalizeLocationScopeAsync( - db, - input.AppliedLocationType, - locationIds, - input.LocationIds is not null || input.AppliedLocationIds is not null, - partnerContext, - string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) ? regionIds : null); - locationIds = locationIdsForSave; + string locationType; + List locationIds; + if (locationHasAll) + { + locationType = ScopeAll; + locationIds = new List(); + } + else + { + var locationResult = await AllScopeBindingHelper.NormalizeLocationScopeAsync( + db, + input.AppliedLocationType, + concreteLocations, + hasLocationArray, + partnerContext, + string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) ? regionIds : null); + locationType = locationResult.Type; + locationIds = locationResult.Ids; + } ValidateDimensionType("Company", partnerType); ValidateDimensionType("Region", regionType); @@ -133,7 +164,9 @@ public static class LabelTemplateScopeHelper || string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) || string.Equals(locationType, ScopeSpecified, StringComparison.OrdinalIgnoreCase); - if (anySpecified) + if (anySpecified + && (string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) + || string.Equals(locationType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))) { var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( db, @@ -410,6 +443,13 @@ public static class LabelTemplateScopeHelper ? AllLocationsDisplay : FormatLocationNames(lIds, locById); + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, + pIds, + rIds, + lIds, + ScopeAllEchoHelper.ForLabelTemplateDimensions(partnerType, regionType, locationType)); + result[template.Id] = new LabelTemplateScopeDisplay { Company = companyDisplay, @@ -418,9 +458,9 @@ public static class LabelTemplateScopeHelper AppliedPartnerType = partnerType, AppliedRegionType = regionType, AppliedLocationType = locationType, - PartnerIds = pIds, - RegionIds = rIds, - LocationIds = lIds + PartnerIds = collapsed.PartnerIds, + RegionIds = collapsed.RegionIds, + LocationIds = collapsed.LocationIds }; } @@ -428,31 +468,58 @@ public static class LabelTemplateScopeHelper } /// - /// 列表权限:按当前用户可见门店筛选模板(各维度 AND)。 - /// null 时不限制(管理员且未传 Query 筛选); - /// 非空时仅返回与可见 Company/Region/Location 匹配的模板,不包含未绑定任何范围的「全局」模板。 + /// 列表筛选:按当前用户可见范围筛选模板。 + /// 仅传 partnerId 时只按 Company 维度过滤(多公司绑定不会被门店 AND 误杀)。 /// public static async Task> ApplyTemplateScopeFilterAsync( ISqlSugarClient db, ISugarQueryable query, - List? scopedLocationIds) + List? scopedLocationIds, + string? partnerId = null, + string? groupId = null, + string? locationId = null) { + var hasExtendedScope = await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(db); + var schema = await LabelTemplateScopeSchemaHelper.GetStatusAsync(db); + + // 仅 PartnerId:只返回 fl_label_template_partner 含该公司的模板。 + // 不含 AppliedPartnerType=ALL(全公司模板在未传 PartnerId 时可见); + // 也不把「无关联行」当成 ALL,避免其他公司数据漏出。 + if (!LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter(partnerId, groupId, locationId) + && !string.IsNullOrWhiteSpace(partnerId)) + { + var pid = partnerId.Trim(); + if (!schema.HasPartnerScopeTable) + { + return query.Where(_ => false); + } + + return query.Where(t => + SqlFunc.Subqueryable() + .Where(p => p.TemplateId == t.Id && p.PartnerId == pid) + .Any()); + } + if (scopedLocationIds is null) { return query; } - var hasExtendedScope = await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(db); - if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + // 无可见门店:保留 Location=ALL(及无 location 关联)的模板 + return query.Where(t => + t.AppliedLocationType == ScopeAll + || !SqlFunc.Subqueryable() + .Where(l => l.TemplateId == t.Id) + .Any()); } if (!hasExtendedScope) { return query.Where(t => - SqlFunc.Subqueryable() + t.AppliedLocationType == ScopeAll + || SqlFunc.Subqueryable() .Where(l => l.TemplateId == t.Id && scopedLocationIds.Contains(l.LocationId)) .Any()); } @@ -462,6 +529,30 @@ public static class LabelTemplateScopeHelper var scopedGroupIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( db, scopedLocationIds); + // Company:AppliedPartnerType=ALL 或关联命中(不用「无行=ALL」) + if (schema.HasAppliedPartnerTypeColumn) + { + return query.Where( + """ + (AppliedPartnerType = @all + OR EXISTS (SELECT 1 FROM fl_label_template_partner p + WHERE p.TemplateId = fl_label_template.Id AND p.PartnerId IN (@partnerIds))) + AND (NOT EXISTS (SELECT 1 FROM fl_label_template_region r WHERE r.TemplateId = fl_label_template.Id) + OR EXISTS (SELECT 1 FROM fl_label_template_region r + WHERE r.TemplateId = fl_label_template.Id AND r.GroupId IN (@groupIds))) + AND (AppliedLocationType = @all + OR EXISTS (SELECT 1 FROM fl_label_template_location l + WHERE l.TemplateId = fl_label_template.Id AND l.LocationId IN (@locs))) + """, + new + { + all = ScopeAll, + partnerIds = scopedPartnerIds, + groupIds = scopedGroupIds.Count > 0 ? scopedGroupIds : new List { "__none__" }, + locs = scopedLocationIds + }); + } + return query.Where(t => SqlFunc.Subqueryable() .Where(p => p.TemplateId == t.Id && scopedPartnerIds.Contains(p.PartnerId)) @@ -576,15 +667,16 @@ public static class LabelTemplateScopeHelper private static async Task ValidateRegionIdsExistAsync(ISqlSugarClient db, List regionIds) { - if (regionIds.Count == 0) + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + if (ids.Count == 0) { return; } var count = await db.Queryable() - .Where(x => !x.IsDeleted && regionIds.Contains(x.Id)) + .Where(x => !x.IsDeleted && ids.Contains(x.Id)) .CountAsync(); - if (count != regionIds.Count) + if (count != ids.Count) { throw new UserFriendlyException("存在无效的 Region(regionIds/groupIds),请刷新后重试"); } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs index 4c47ffb..ad1fdba 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs @@ -306,13 +306,14 @@ public static class LocationScopeBindingHelper merged.Add(id); } - var fromGroups = await ResolveLocationIdsFromGroupIdsAsync(db, groupIds); + var concreteGroupIds = FilterConcreteScopeIds(groupIds); + var fromGroups = await ResolveLocationIdsFromGroupIdsAsync(db, concreteGroupIds); foreach (var id in fromGroups) { merged.Add(id); } - foreach (var id in NormalizeIds(locationIds)) + foreach (var id in FilterConcreteScopeIds(locationIds)) { merged.Add(id); } @@ -355,15 +356,19 @@ public static class LocationScopeBindingHelper var regionHasAll = ContainsAllScopeSentinel(regionIds); var concreteRegions = FilterConcreteScopeIds(regionIds); - // 1. locationIds 含 ALL → 按 partner 展开全部门店 + // 1. locationIds 含 ALL:有具体 Region 时展开该 Region;否则按 Company 全部门店 if (locationHasAll) { - if (normalizedPartners.Count == 0) + var expanded = await ExpandScopedAllLocationsForSaveAsync( + db, + normalizedPartners.Count > 0 ? normalizedPartners : null, + concreteRegions.Count > 0 ? concreteRegions : null); + if (expanded is not null) { - throw new UserFriendlyException("选择全部门店时需指定 Company(partnerId / partnerIds)"); + return expanded; } - return await ResolveLocationIdsFromPartnerIdsAsync(db, normalizedPartners); + throw new UserFriendlyException("选择全部门店时需指定 Company(partnerId / partnerIds)或 Region"); } // 2. 具体门店 +(无区域 / 区域为 ALL)→ 只绑这些门店,避免 regionIds=ALL 盖掉单店 @@ -383,14 +388,29 @@ public static class LocationScopeBindingHelper return await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, normalizedPartners, null); } - // 4. 具体 regionIds → 按 Region 展开门店(忽略同传 locationIds) + // 4. 具体 regionIds if (concreteRegions.Count > 0) { - return await MergeToLocationIdsAsync( + var fromRegions = await ExpandScopedAllLocationsForSaveAsync( db, - (IReadOnlyList?)null, - concreteRegions, - null); + normalizedPartners.Count > 0 ? normalizedPartners : null, + concreteRegions); + var regionLocs = fromRegions ?? new List(); + + // 同传具体门店:若已覆盖该区全集(前端 Location=ALL 常展开 Guid)→ 用区内全部门店; + // 否则保留子集(Region 具体 + 部分门店) + if (concreteLocations.Count > 0) + { + if (regionLocs.Count > 0 + && AllScopeBindingHelper.IsFullIdSelection(concreteLocations, regionLocs)) + { + return regionLocs; + } + + return concreteLocations; + } + + return regionLocs; } // 5. 仅有具体 locationIds → 只绑这些门店 @@ -422,11 +442,28 @@ public static class LocationScopeBindingHelper return new List(); } + if (ContainsAllScopeSentinel(locationIds) + || (ContainsAllScopeSentinel(regionIds) + && FilterConcreteScopeIds(locationIds).Count == 0)) + { + throw new UserFriendlyException("门店范围含 ALL 哨兵时应归档为 ALL,不应进入 SPECIFIED 落库校验"); + } + + var concreteRegions = FilterConcreteScopeIds(regionIds); + var concreteLocations = FilterConcreteScopeIds(locationIds); + + // 有具体门店时以门店为准(不再与 Region 并集展开),避免「选 1 个 Location + 同传 Region」落成整 Region 再回显成 ALL + if (concreteLocations.Count > 0) + { + await ValidateLocationIdsExistAsync(db, concreteLocations); + return concreteLocations; + } + var merged = await MergeToLocationIdsAsync( db, (IReadOnlyList?)null, - regionIds, - locationIds); + concreteRegions, + concreteLocations); if (merged.Count == 0) { throw new UserFriendlyException("指定适用区域或门店时,至少需要匹配到一个有效门店"); @@ -437,6 +474,52 @@ public static class LocationScopeBindingHelper } /// + /// 将「范围内 ALL」展开为门店 Id:优先按具体 Region,其次按具体 Company;两者皆无则返回 null(调用方归档全局 ALL)。 + /// + public static async Task?> ExpandScopedAllLocationsForSaveAsync( + ISqlSugarClient db, + IReadOnlyList? partnerIds, + IReadOnlyList? regionIds) + { + var concreteRegions = FilterConcreteScopeIds(regionIds); + if (concreteRegions.Count > 0) + { + var fromRegions = await ResolveLocationIdsFromGroupIdsAsync(db, concreteRegions); + var partners = NormalizeIds(partnerIds); + if (partners.Count > 0) + { + var partnerLocSet = new HashSet( + await ResolveLocationIdsFromPartnerIdsAsync(db, partners), + StringComparer.OrdinalIgnoreCase); + fromRegions = fromRegions.Where(id => partnerLocSet.Contains(id)).ToList(); + } + + if (fromRegions.Count == 0) + { + throw new UserFriendlyException("指定 Region 下未匹配到有效门店"); + } + + await ValidateLocationIdsExistAsync(db, fromRegions); + return fromRegions; + } + + var concretePartners = NormalizeIds(partnerIds); + if (concretePartners.Count > 0) + { + var fromPartners = await ResolveLocationIdsFromPartnerIdsAsync(db, concretePartners); + if (fromPartners.Count == 0) + { + throw new UserFriendlyException("指定 Company 下未匹配到有效门店"); + } + + await ValidateLocationIdsExistAsync(db, fromPartners); + return fromPartners; + } + + return null; + } + + /// /// 根据已绑定门店反推适用的 Company Id(fl_partner.Id)。 /// public static async Task> ResolvePartnerIdsFromLocationIdsAsync( @@ -679,7 +762,7 @@ public static class LocationScopeBindingHelper /// public static async Task ValidateLocationIdsExistAsync(ISqlSugarClient db, IReadOnlyList locationIds) { - var ids = NormalizeIds(locationIds); + var ids = FilterConcreteScopeIds(locationIds); if (ids.Count == 0) { return; diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ScopeAllEchoHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ScopeAllEchoHelper.cs new file mode 100644 index 0000000..2ba7e48 --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ScopeAllEchoHelper.cs @@ -0,0 +1,191 @@ +using SqlSugar; + +namespace FoodLabeling.Application.Helpers; + +/// +/// 编辑/列表回显:库中可存展开 Guid;若绑定覆盖全集则将 Id 数组折叠为 ["ALL"] 哨兵(对齐 Team Member)。 +/// +public static class ScopeAllEchoHelper +{ + public sealed class ScopeAllEchoOptions + { + public bool IsPartnerAll { get; init; } + + public bool IsRegionAll { get; init; } + + public bool IsLocationAll { get; init; } + } + + /// + /// 将 partnerIds / regionIds / locationIds 折叠为 ALL 哨兵(编辑弹窗与列表 Id 数组回显)。 + /// + public static async Task<(List PartnerIds, List RegionIds, List LocationIds)> + CollapseScopeIdsToAllSentinelAsync( + ISqlSugarClient db, + IReadOnlyList? partnerIds, + IReadOnlyList? regionIds, + IReadOnlyList? locationIds, + ScopeAllEchoOptions? options = null) + { + options ??= new ScopeAllEchoOptions(); + var partners = LocationScopeBindingHelper.NormalizeIds(partnerIds); + var regions = LocationScopeBindingHelper.NormalizeIds(regionIds); + var locations = LocationScopeBindingHelper.NormalizeIds(locationIds); + + if (options.IsPartnerAll) + { + partners = new List { AllScopeBindingHelper.ScopeAll }; + } + else if (partners.Count > 0) + { + var allPartners = await AllScopeBindingHelper.ResolveAllPartnerIdsAsync(db); + if (allPartners.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(partners, allPartners)) + { + partners = new List { AllScopeBindingHelper.ScopeAll }; + } + } + + if (options.IsRegionAll) + { + regions = new List { AllScopeBindingHelper.ScopeAll }; + } + else if (regions.Count > 0) + { + regions = await CollapseRegionsIfFullAsync(db, partners, regions); + } + + if (options.IsLocationAll) + { + locations = new List { AllScopeBindingHelper.ScopeAll }; + } + else if (locations.Count > 0) + { + locations = await CollapseLocationsIfFullAsync(db, partners, regions, locations); + } + + return (partners, regions, locations); + } + + /// AvailabilityType=ALL 时 Region 与 Location 均为 ALL。 + public static ScopeAllEchoOptions ForAvailabilityAll(bool isAll) => + new() { IsRegionAll = isAll, IsLocationAll = isAll }; + + /// LabelTemplate 各维度独立 ALL 标记。 + public static ScopeAllEchoOptions ForLabelTemplateDimensions( + string? appliedPartnerType, + string? appliedRegionType, + string? appliedLocationType) => + new() + { + IsPartnerAll = AllScopeBindingHelper.IsDeclaredAll(appliedPartnerType), + IsRegionAll = AllScopeBindingHelper.IsDeclaredAll(appliedRegionType), + IsLocationAll = AllScopeBindingHelper.IsDeclaredAll(appliedLocationType) + }; + + /// 标签类型/分类/多选项:Company + Region + Location 可用范围。 + public static ScopeAllEchoOptions ForLabelEntityScope( + string? appliedPartnerType, + string? appliedRegionType, + string? availabilityType) => + new() + { + IsPartnerAll = AllScopeBindingHelper.IsDeclaredAll(appliedPartnerType), + IsRegionAll = AllScopeBindingHelper.IsDeclaredAll(appliedRegionType), + IsLocationAll = AllScopeBindingHelper.IsDeclaredAll(availabilityType) + }; + + /// 兼容旧调用:无 Region 类型时用 AvailabilityType 同时驱动 Region/Location。 + public static ScopeAllEchoOptions ForLabelEntityScope( + string? appliedPartnerType, + string? availabilityType) => + ForLabelEntityScope(appliedPartnerType, availabilityType, availabilityType); + + /// Label:AppliedRegionType=ALL 且未落门店快照时 Region/Location 均为 ALL。 + public static ScopeAllEchoOptions ForLabelRegionScope( + string? appliedRegionType, + IReadOnlyList locationIds) + { + var isRegionAll = AllScopeBindingHelper.IsDeclaredAll(appliedRegionType); + return new ScopeAllEchoOptions + { + IsRegionAll = isRegionAll, + IsLocationAll = isRegionAll && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0 + }; + } + + private static async Task> CollapseRegionsIfFullAsync( + ISqlSugarClient db, + IReadOnlyList partners, + IReadOnlyList regions) + { + var partnerContext = ResolvePartnerContextForCollapse(partners); + var allRegionIds = partnerContext is { Count: > 0 } + ? await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(db, partnerContext) + : await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, null); + + if (allRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(regions, allRegionIds)) + { + return new List { AllScopeBindingHelper.ScopeAll }; + } + + return regions.ToList(); + } + + private static async Task> CollapseLocationsIfFullAsync( + ISqlSugarClient db, + IReadOnlyList partners, + IReadOnlyList regions, + IReadOnlyList locations) + { + var partnerContext = ResolvePartnerContextForCollapse(partners); + List allLocationIds; + + if (regions.Count == 1 && AllScopeBindingHelper.IsDeclaredAll(regions[0])) + { + allLocationIds = partnerContext is { Count: > 0 } + ? await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerContext) + : await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, null, null); + } + else if (regions.Count > 0 && !regions.Any(r => AllScopeBindingHelper.IsDeclaredAll(r))) + { + allLocationIds = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partnerContext, regions); + } + else if (partnerContext is { Count: > 0 }) + { + allLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerContext); + } + else + { + allLocationIds = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, null, null); + } + + if (allLocationIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(locations, allLocationIds)) + { + // 具体 Region:覆盖该区全部门店时一律回显 ["ALL"](含区内仅 1 店;前端 ALL 常展开为具体 Guid) + var hasConcreteRegions = regions.Count > 0 + && !regions.Any(AllScopeBindingHelper.IsDeclaredAll); + if (hasConcreteRegions) + { + return new List { AllScopeBindingHelper.ScopeAll }; + } + + // Region=ALL(或无具体 Region)时:仅多店才折叠,避免「Region=ALL + 选 1 店」误成 Location ALL + if (locations.Count > 1 || allLocationIds.Count > 1) + { + return new List { AllScopeBindingHelper.ScopeAll }; + } + } + + return locations.ToList(); + } + + private static List? ResolvePartnerContextForCollapse(IReadOnlyList partners) + { + if (partners.Count == 0 || AllScopeBindingHelper.IsDeclaredAll(partners[0])) + { + return null; + } + + return partners.ToList(); + } +} diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberScopeDisplayHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberScopeDisplayHelper.cs index 35efd90..e0617c2 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberScopeDisplayHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberScopeDisplayHelper.cs @@ -26,8 +26,14 @@ public static class TeamMemberScopeDisplayHelper Guid? roleId, IReadOnlyList partnerIds, IReadOnlyList regionIds, - IReadOnlyList assigned) + IReadOnlyList assigned, + string? appliedLocationType = null) { + if (AllScopeBindingHelper.IsDeclaredAll(appliedLocationType)) + { + return AllLocationDisplay; + } + var assignedIds = assigned .Select(x => x.Id) .Where(x => !string.IsNullOrWhiteSpace(x)) @@ -42,10 +48,13 @@ public static class TeamMemberScopeDisplayHelper if (partnerIds.Count > 0) { - var allPartnerLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync( - db, partnerIds); - if (allPartnerLocationIds.Count > 0 && - AllScopeBindingHelper.IsFullIdSelection(assignedIds, allPartnerLocationIds)) + // 有具体 Region 时按 Region 范围内全选判断;否则按 Company 全部门店 + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + var universe = concreteRegions.Count > 0 + ? await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partnerIds, concreteRegions) + : await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerIds); + if (universe.Count > 0 && + AllScopeBindingHelper.IsFullIdSelection(assignedIds, universe)) { return AllLocationDisplay; } @@ -61,8 +70,15 @@ public static class TeamMemberScopeDisplayHelper ISqlSugarClient db, IReadOnlyList partnerIds, IReadOnlyList regionIds, - IReadOnlyDictionary regionNameMap) + IReadOnlyDictionary regionNameMap, + string? appliedRegionType = null) { + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType) + || (regionIds.Count == 1 && AllScopeBindingHelper.IsDeclaredAll(regionIds[0]))) + { + return FoodLabelingDisplayConsts.AllRegion; + } + if (regionIds.Count == 0) { return FoodLabelingDisplayConsts.NotAvailable; @@ -85,7 +101,7 @@ public static class TeamMemberScopeDisplayHelper } /// - /// 编辑回显:库中存展开后的 Guid;若绑定已覆盖 Company 下全部 Region/门店,则将对应 Id 列表折叠为 ["ALL"],供前端勾选 ALL。 + /// 编辑回显:库中存展开后的 Guid;结合 AppliedRegionType/AppliedLocationType 折叠为 ["ALL"]。 /// public static async Task<(List RegionIds, List LocationIds, List Assigned)> CollapseScopeIdsToAllSentinelForEditAsync( @@ -93,29 +109,71 @@ public static class TeamMemberScopeDisplayHelper IReadOnlyList partnerIds, IReadOnlyList regionIds, IReadOnlyList locationIds, - IReadOnlyList assigned) + IReadOnlyList assigned, + string? appliedRegionType = null, + string? appliedLocationType = null) { var partners = LocationScopeBindingHelper.NormalizeIds(partnerIds); var regions = LocationScopeBindingHelper.NormalizeIds(regionIds); var locations = LocationScopeBindingHelper.NormalizeIds(locationIds); var assignedList = assigned?.ToList() ?? new List(); + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType)) + { + regions = new List { AllScopeBindingHelper.ScopeAll }; + } + + if (AllScopeBindingHelper.IsDeclaredAll(appliedLocationType)) + { + locations = new List { AllScopeBindingHelper.ScopeAll }; + assignedList = AllLocationDisplay; + return (regions, locations, assignedList); + } + if (partners.Count == 0) { return (regions, locations, assignedList); } - var allRegionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(db, partners); - if (allRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(regions, allRegionIds)) + if (!AllScopeBindingHelper.IsDeclaredAll(appliedRegionType)) { - regions = new List { AllScopeBindingHelper.ScopeAll }; + var allRegionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(db, partners); + if (allRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(regions, allRegionIds)) + { + regions = new List { AllScopeBindingHelper.ScopeAll }; + } } - var allLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners); + // Region=ALL + 具体门店:禁止再按「派生单 Region 区内全选」把 Location 折成 ALL + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType) + || (regions.Count == 1 && AllScopeBindingHelper.IsDeclaredAll(regions[0]))) + { + var partnerUniverse = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync( + db, partners); + if (partnerUniverse.Count > 1 + && locations.Count > 1 + && AllScopeBindingHelper.IsFullIdSelection(locations, partnerUniverse)) + { + locations = new List { AllScopeBindingHelper.ScopeAll }; + assignedList = AllLocationDisplay; + } + + return (regions, locations, assignedList); + } + + // 具体 Region:按区内门店全集判断是否折叠 locationIds + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regions); + var allLocationIds = concreteRegions.Count > 0 + ? await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partners, concreteRegions) + : await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners); if (allLocationIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(locations, allLocationIds)) { - locations = new List { AllScopeBindingHelper.ScopeAll }; - assignedList = AllLocationDisplay; + var hasConcreteRegions = concreteRegions.Count > 0; + if (hasConcreteRegions || locations.Count > 1 || allLocationIds.Count > 1) + { + locations = new List { AllScopeBindingHelper.ScopeAll }; + assignedList = AllLocationDisplay; + } } return (regions, locations, assignedList); diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelCategoryDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelCategoryDbEntity.cs index 7427ea6..93ef527 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelCategoryDbEntity.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelCategoryDbEntity.cs @@ -49,6 +49,11 @@ public class FlLabelCategoryDbEntity public string AvailabilityType { get; set; } = "ALL"; /// + /// 适用 Region 范围:ALL / SPECIFIED(ALL 时可同时 SPECIFIED 门店快照,表示全区下指定门店) + /// + public string AppliedRegionType { get; set; } = "ALL"; + + /// /// 适用 Company 范围:ALL / SPECIFIED /// public string AppliedPartnerType { get; set; } = "ALL"; diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelDbEntity.cs index 88054d5..26d434c 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelDbEntity.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelDbEntity.cs @@ -28,6 +28,11 @@ public class FlLabelDbEntity public string? LocationId { get; set; } + /// + /// 适用 Company(fl_partner.Id,单选);Region/Location 为 ALL 时用于回显与范围校验。 + /// + public string? PartnerId { get; set; } + public string? LabelCategoryId { get; set; } public string? LabelTypeId { get; set; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelMultipleOptionDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelMultipleOptionDbEntity.cs index c153dad..3094e5b 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelMultipleOptionDbEntity.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelMultipleOptionDbEntity.cs @@ -36,6 +36,11 @@ public class FlLabelMultipleOptionDbEntity public string AvailabilityType { get; set; } = "ALL"; /// + /// 适用 Region 范围:ALL / SPECIFIED + /// + public string AppliedRegionType { get; set; } = "ALL"; + + /// /// 适用 Company 范围:ALL / SPECIFIED /// public string AppliedPartnerType { get; set; } = "ALL"; diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTypeDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTypeDbEntity.cs index 1a7bccc..96fc90e 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTypeDbEntity.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTypeDbEntity.cs @@ -34,6 +34,11 @@ public class FlLabelTypeDbEntity public string AvailabilityType { get; set; } = "ALL"; /// + /// 适用 Region 范围:ALL / SPECIFIED + /// + public string AppliedRegionType { get; set; } = "ALL"; + + /// /// 适用 Company 范围:ALL / SPECIFIED /// public string AppliedPartnerType { get; set; } = "ALL"; diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs index 2852756..7874286 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs @@ -46,6 +46,16 @@ public class FlProductCategoryDbEntity /// public string AvailabilityType { get; set; } = "ALL"; + /// + /// 适用 Region 范围:ALL / SPECIFIED(ALL 时可同时 SPECIFIED 门店快照) + /// + public string AppliedRegionType { get; set; } = "ALL"; + + /// + /// 适用 Company 范围:ALL / SPECIFIED + /// + public string AppliedPartnerType { get; set; } = "ALL"; + public int OrderNum { get; set; } } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryPartnerDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryPartnerDbEntity.cs new file mode 100644 index 0000000..e4c92a5 --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryPartnerDbEntity.cs @@ -0,0 +1,18 @@ +using SqlSugar; + +namespace FoodLabeling.Application.Services.DbModels; + +[SugarTable("fl_product_category_partner")] +public class FlProductCategoryPartnerDbEntity +{ + [SugarColumn(IsPrimaryKey = true)] + public string Id { get; set; } = string.Empty; + + public string CategoryId { get; set; } = string.Empty; + + public string PartnerId { get; set; } = string.Empty; + + public DateTime CreationTime { get; set; } + + public string? CreatorId { get; set; } +} diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs index 7d31ad8..6c39b92 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs @@ -36,4 +36,9 @@ public class FlProductDbEntity /// 适用门店:ALL / SPECIFIED(ALL 时动态包含后续新增门店) /// public string AvailabilityType { get; set; } = "SPECIFIED"; + + /// + /// 适用 Region:ALL / SPECIFIED(支持 Region=ALL + 指定门店) + /// + public string AppliedRegionType { get; set; } = "ALL"; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTeamMemberScopeDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTeamMemberScopeDbEntity.cs new file mode 100644 index 0000000..bf0ac0a --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTeamMemberScopeDbEntity.cs @@ -0,0 +1,27 @@ +using SqlSugar; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Application.Services.DbModels; + +/// +/// Team Member 适用范围维度(Region/Location 的 ALL/SPECIFIED),与 userlocation 快照配合回显。 +/// +[IgnoreCodeFirst] +[SugarTable("fl_team_member_scope")] +public class FlTeamMemberScopeDbEntity +{ + [SugarColumn(IsPrimaryKey = true, Length = 36)] + public string UserId { get; set; } = string.Empty; + + /// 适用 Region:ALL / SPECIFIED + [SugarColumn(Length = 20)] + public string AppliedRegionType { get; set; } = "SPECIFIED"; + + /// 适用 Location:ALL / SPECIFIED + [SugarColumn(Length = 20)] + public string AppliedLocationType { get; set; } = "SPECIFIED"; + + public DateTime? CreationTime { get; set; } + + public DateTime? LastModificationTime { get; set; } +} diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs index c478615..4c8f5dc 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs @@ -50,7 +50,11 @@ public class LabelAppService : ApplicationService, ILabelAppService .Where(l => !l.IsDeleted) .WhereIF(!string.IsNullOrWhiteSpace(labelCategoryId), l => l.LabelCategoryId == labelCategoryId) .WhereIF(!string.IsNullOrWhiteSpace(labelTypeId), l => l.LabelTypeId == labelTypeId) - .WhereIF(input.State != null, l => l.State == input.State); + .WhereIF(input.State != null, l => l.State == input.State) + // 已落库 PartnerId 的标签:按公司筛选时排除其他公司 + .WhereIF( + !string.IsNullOrWhiteSpace(partnerId), + l => l.PartnerId == null || l.PartnerId == "" || l.PartnerId == partnerId); if (!string.IsNullOrWhiteSpace(templateCode)) { @@ -69,11 +73,16 @@ public class LabelAppService : ApplicationService, ILabelAppService groupId, locationId); var filterGroupId = groupId?.Trim(); - if (scopedLocationIds is not null) + var applyLocationFilter = LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter( + partnerId, groupId, locationId); + + if (scopedLocationIds is not null && applyLocationFilter) { if (scopedLocationIds.Count == 0) { - labelIdsQuery = labelIdsQuery.Where(_ => false); + // 无可见门店:仍保留 AppliedRegionType=ALL + labelIdsQuery = LabelRegionScopeHelper.ApplyLabelAllRegionOnlyFilter( + db, labelIdsQuery, regionSchema); } else if (!string.IsNullOrWhiteSpace(filterGroupId)) { @@ -86,6 +95,24 @@ public class LabelAppService : ApplicationService, ILabelAppService db, labelIdsQuery, scopedLocationIds, regionSchema); } } + else if (!string.IsNullOrWhiteSpace(partnerId) && !applyLocationFilter) + { + // 仅 PartnerId:Region=ALL 或与该公司门店/区域有交集 + if (scopedLocationIds is null) + { + // 管理员且未展开到门店:不过滤 + } + else if (scopedLocationIds.Count == 0) + { + labelIdsQuery = LabelRegionScopeHelper.ApplyLabelAllRegionOnlyFilter( + db, labelIdsQuery, regionSchema); + } + else + { + labelIdsQuery = LabelRegionScopeHelper.ApplyLabelLocationListFilter( + db, labelIdsQuery, scopedLocationIds, regionSchema); + } + } // 按产品筛选:存在 label-product 关联即可 if (!string.IsNullOrWhiteSpace(productId)) @@ -193,6 +220,7 @@ public class LabelAppService : ApplicationService, ILabelAppService db, lid, applied, locIds); locationScopeMap[lid] = await LabelRegionScopeHelper.BuildLocationDisplayAsync( db, applied, locIds); + locationIdsMap[lid] = locationScopeMap[lid].LocationIds; } // 查询 products 并拼接 @@ -307,37 +335,51 @@ public class LabelAppService : ApplicationService, ILabelAppService List regionIdsForDto; List partnerIds; + var storedPartnerId = label.PartnerId?.Trim(); var isDynamicAll = string.Equals(appliedRegionType, LabelRegionScopeHelper.AppliedRegionAll, StringComparison.OrdinalIgnoreCase) && locationIdList.Count == 0; - // ALL 落库不写 Id 快照;详情回显展开为当前可见全集,便于编辑页 Select All if (isDynamicAll) { - var scopedLocationIds = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - if (scopedLocationIds is null) + if (!string.IsNullOrWhiteSpace(storedPartnerId)) { - locationIdList = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, null, null); - regionIdsForDto = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, null); - partnerIds = await AllScopeBindingHelper.ResolveAllPartnerIdsAsync(db); + partnerIds = new List { storedPartnerId }; } else { - locationIdList = scopedLocationIds; - regionIdsForDto = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( - db, locationIdList); - partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( - db, locationIdList); + var scopedLocationIds = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( + CurrentUser, _dbContext, null, null, null); + partnerIds = scopedLocationIds is null + ? new List { AllScopeBindingHelper.ScopeAll } + : await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, scopedLocationIds); } + + regionIdsForDto = new List { AllScopeBindingHelper.ScopeAll }; + locationIdList = new List { AllScopeBindingHelper.ScopeAll }; } else { - partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( - db, locationIdList); + partnerIds = !string.IsNullOrWhiteSpace(storedPartnerId) + ? new List { storedPartnerId } + : await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, locationIdList); var storedRegionScope = await LabelRegionScopeHelper.BuildScopeDisplayAsync( db, label.Id, appliedRegionType, locationIdList); regionIdsForDto = storedRegionScope.RegionIds; + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + db, + partnerIds, + regionIdsForDto, + locationIdList, + ScopeAllEchoHelper.ForLabelRegionScope(appliedRegionType, locationIdList)); + // 已显式落库 PartnerId 时不折叠成 Company ALL + if (string.IsNullOrWhiteSpace(storedPartnerId)) + { + partnerIds = collapsed.PartnerIds; + } + + regionIdsForDto = collapsed.RegionIds; + locationIdList = collapsed.LocationIds; } var regionDisplay = isDynamicAll @@ -349,6 +391,11 @@ public class LabelAppService : ApplicationService, ILabelAppService : (await LabelRegionScopeHelper.BuildLocationDisplayAsync( db, appliedRegionType, locationIdList)).Location; + var companyIdsEcho = partnerIds.Count == 1 + && !AllScopeBindingHelper.IsDeclaredAll(partnerIds[0]) + ? new List { partnerIds[0] } + : partnerIds.Where(x => !AllScopeBindingHelper.IsDeclaredAll(x)).ToList(); + return new LabelGetOutputDto { Id = label.LabelCode ?? string.Empty, @@ -357,8 +404,9 @@ public class LabelAppService : ApplicationService, ILabelAppService LocationIds = locationIdList, Location = locationDisplay, LocationName = locationDisplay, - PartnerId = partnerIds.Count > 0 ? partnerIds[0] : null, + PartnerId = companyIdsEcho.Count > 0 ? companyIdsEcho[0] : (partnerIds.Count > 0 ? partnerIds[0] : null), PartnerIds = partnerIds, + CompanyIds = companyIdsEcho, AppliedRegionType = appliedRegionType, Region = regionDisplay, RegionIds = regionIdsForDto, @@ -406,13 +454,16 @@ public class LabelAppService : ApplicationService, ILabelAppService await EnsureLabelTypeExistsIfProvidedAsync(input.LabelTypeId); + var partnerId = await ResolveSinglePartnerIdForSaveAsync(input.PartnerId, input.PartnerIds, input.CompanyIds); + var partnerContext = string.IsNullOrWhiteSpace(partnerId) ? null : new[] { partnerId }; var scope = await LabelRegionScopeHelper.ResolveScopeForSaveAsync( _dbContext.SqlSugarClient, input.AppliedRegionType, input.RegionIds, input.GroupIds, input.LocationId, - input.LocationIds); + input.LocationIds, + partnerContext); var template = await LabelTemplateQueryHelper.QueryProjected(_dbContext.SqlSugarClient) .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim()); @@ -445,6 +496,7 @@ public class LabelAppService : ApplicationService, ILabelAppService LabelName = labelName, TemplateId = template.Id, LocationId = scope.PrimaryLocationId, + PartnerId = partnerId, LabelCategoryId = input.LabelCategoryId?.Trim(), LabelTypeId = NormalizeOptionalLabelTypeId(input.LabelTypeId), State = input.State, @@ -670,6 +722,7 @@ public class LabelAppService : ApplicationService, ILabelAppService TemplateCode = templateCode, PartnerId = item.PartnerId, PartnerIds = item.PartnerIds, + CompanyIds = item.CompanyIds, AppliedRegionType = item.AppliedRegionType, RegionIds = item.RegionIds, GroupIds = item.GroupIds, @@ -715,13 +768,16 @@ public class LabelAppService : ApplicationService, ILabelAppService await EnsureLabelTypeExistsIfProvidedAsync(input.LabelTypeId); + var partnerId = await ResolveSinglePartnerIdForSaveAsync(input.PartnerId, input.PartnerIds, input.CompanyIds); + var partnerContext = string.IsNullOrWhiteSpace(partnerId) ? null : new[] { partnerId }; var scope = await LabelRegionScopeHelper.ResolveScopeForSaveAsync( _dbContext.SqlSugarClient, input.AppliedRegionType, input.RegionIds, input.GroupIds, input.LocationId, - input.LocationIds); + input.LocationIds, + partnerContext); var template = await LabelTemplateQueryHelper.QueryProjected(_dbContext.SqlSugarClient) .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim()); @@ -735,6 +791,7 @@ public class LabelAppService : ApplicationService, ILabelAppService label.LabelName = input.LabelName?.Trim() ?? label.LabelName; label.TemplateId = template.Id; label.LocationId = scope.PrimaryLocationId; + label.PartnerId = partnerId; label.LabelCategoryId = input.LabelCategoryId?.Trim(); label.LabelTypeId = NormalizeOptionalLabelTypeId(input.LabelTypeId); label.State = input.State; @@ -1106,6 +1163,59 @@ public class LabelAppService : ApplicationService, ILabelAppService return string.IsNullOrWhiteSpace(id) ? null : id; } + /// + /// 标签适用 Company 单选:合并 partnerId / partnerIds / companyIds,拒绝 ALL 与多选。 + /// + private async Task ResolveSinglePartnerIdForSaveAsync( + string? partnerId, + IReadOnlyList? partnerIds, + IReadOnlyList? companyIds) + { + var merged = LabelEntityPartnerScopeHelper.NormalizePartnerIds(partnerIds, companyIds); + if (!string.IsNullOrWhiteSpace(partnerId)) + { + var pid = partnerId.Trim(); + if (LocationScopeBindingHelper.IsAllScopeSentinel(pid)) + { + throw new UserFriendlyException("标签适用 Company 不支持 ALL,请传单个具体 Company Id"); + } + + if (merged.Count > 0 + && (merged.Count > 1 + || !string.Equals(merged[0], pid, StringComparison.OrdinalIgnoreCase))) + { + throw new UserFriendlyException("partnerId 与 companyIds/partnerIds 不一致"); + } + + merged = new List { pid }; + } + + if (AllScopeBindingHelper.HasAllScopeSentinelSelection(merged)) + { + throw new UserFriendlyException("标签适用 Company 不支持 ALL,请传单个具体 Company Id"); + } + + var concrete = LocationScopeBindingHelper.FilterConcreteScopeIds(merged); + if (concrete.Count > 1) + { + throw new UserFriendlyException("标签适用 Company 仅支持单选(companyIds 最多传 1 个)"); + } + + if (concrete.Count == 0) + { + return null; + } + + var exists = await _dbContext.SqlSugarClient.Queryable() + .AnyAsync(x => !x.IsDeleted && x.Id == concrete[0]); + if (!exists) + { + throw new UserFriendlyException("存在无效的 Company(partnerId/companyIds),请刷新后重试"); + } + + return concrete[0]; + } + private async Task EnsureLabelTypeExistsIfProvidedAsync(string? labelTypeId) { var id = NormalizeOptionalLabelTypeId(labelTypeId); diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelCategoryAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelCategoryAppService.cs index df835c8..e087fb9 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelCategoryAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelCategoryAppService.cs @@ -122,23 +122,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ var dto = MapToGetOutput(entity); await ApplyPartnerScopeToGetOutputAsync(dto, entity.Id); - if (string.Equals(entity.AvailabilityType, "ALL", StringComparison.OrdinalIgnoreCase)) - { - var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - var (partners, regions, locations) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, dto.PartnerIds); - if (dto.PartnerIds.Count == 0) - { - dto.PartnerIds = partners; - dto.CompanyIds = partners; - } - - dto.RegionIds = regions; - dto.GroupIds = regions; - dto.LocationIds = locations; - } - else if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) { var locationIds = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.CategoryId == entity.Id) @@ -151,6 +135,21 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ dto.GroupIds = regionIds; } + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + dto.PartnerIds, + dto.RegionIds, + dto.LocationIds, + ScopeAllEchoHelper.ForLabelEntityScope( + dto.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType)); + dto.PartnerIds = collapsed.PartnerIds; + dto.CompanyIds = collapsed.PartnerIds; + dto.RegionIds = collapsed.RegionIds; + dto.GroupIds = collapsed.RegionIds; + dto.LocationIds = collapsed.LocationIds; + return dto; } @@ -165,7 +164,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ var displayText = input.DisplayText?.Trim(); var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); var duplicated = await _dbContext.SqlSugarClient.Queryable() .AnyAsync(x => !x.IsDeleted && (x.CategoryCode == code || x.CategoryName == name)); @@ -194,6 +193,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ State = input.State, ButtonAppearance = appearance, AvailabilityType = availabilityType, + AppliedRegionType = appliedRegionType, AppliedPartnerType = partnerScope.AppliedPartnerType, OrderNum = input.OrderNum }; @@ -222,7 +222,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ var displayText = input.DisplayText?.Trim(); var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); var duplicated = await _dbContext.SqlSugarClient.Queryable() .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.CategoryCode == code || x.CategoryName == name)); @@ -238,6 +238,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ entity.State = input.State; entity.ButtonAppearance = appearance; entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; entity.AppliedPartnerType = partnerScope.AppliedPartnerType; entity.OrderNum = input.OrderNum; entity.LastModificationTime = DateTime.Now; @@ -298,7 +299,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ }; } - private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, List LocationIds)> ResolveCategoryScopeForSaveAsync( + private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, string AppliedRegionType, List LocationIds)> ResolveCategoryScopeForSaveAsync( LabelCategoryCreateInputVo input) { var partnerScope = await LabelEntityPartnerScopeHelper.ResolvePartnerScopeForSaveAsync( @@ -307,10 +308,8 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ input.PartnerIds, input.CompanyIds); - var regionIds = NormalizeRegionIds(input); - var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds); + var mergedRegionIds = NormalizeRegionIds(input); var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null; - var partnerContext = string.Equals( partnerScope.AppliedPartnerType, LabelEntityPartnerScopeHelper.ScopeSpecified, @@ -318,40 +317,15 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ ? partnerScope.PartnerIds : null; - if (await AllScopeBindingHelper.ShouldTreatMergedLocationScopeAsAllAsync( - _dbContext.SqlSugarClient, - input.AvailabilityType, - regionIds, - explicitLocationIds, - hasScopeArrays, - partnerContext)) - { - return (partnerScope, "ALL", new List()); - } - - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant(); - if (regionIds.Count > 0 || explicitLocationIds.Count > 0) - { - availabilityType = "SPECIFIED"; - } - else if (hasScopeArrays && AllScopeBindingHelper.IsDeclaredAll(availabilityType)) - { - availabilityType = "ALL"; - } - - if (availabilityType != "ALL" && availabilityType != "SPECIFIED") - { - throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); - } - - var locationSpecified = string.Equals(availabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase); - var savedLocationIds = await LocationScopeBindingHelper.ResolveEntityLocationIdsForSaveAsync( + var locScope = await AllScopeBindingHelper.ResolveLabelEntityRegionLocationForSaveAsync( _dbContext.SqlSugarClient, - locationSpecified, - regionIds, - explicitLocationIds); + input.AvailabilityType, + partnerContext, + mergedRegionIds, + input.LocationIds, + hasScopeArrays); - return (partnerScope, locationSpecified ? "SPECIFIED" : "ALL", savedLocationIds); + return (partnerScope, locScope.AvailabilityType, locScope.AppliedRegionType, locScope.LocationIds); } private static List NormalizeRegionIds(LabelCategoryCreateInputVo input) @@ -384,6 +358,11 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ query = await LabelEntityPartnerScopeHelper.ApplyCategoryPartnerListFilterAsync( _dbContext.SqlSugarClient, query, scopedPartnerIds); + if (!LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter(partnerId, groupId, locationId)) + { + return query; + } + if (scopedLocationIds is null) { return query; @@ -473,8 +452,8 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ { Region = AllRegionsDisplay, Location = AllLocationsDisplay, - RegionIds = new List(), - LocationIds = new List() + RegionIds = new List { AllScopeBindingHelper.ScopeAll }, + LocationIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -580,14 +559,26 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ locationIds, regions, locationNames, - partnerContext); + partnerContext, + entity?.AppliedRegionType); + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + partnerContext, + regionIds, + locationIds, + entity is not null + ? ScopeAllEchoHelper.ForLabelEntityScope( + entity.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType) + : null); result[catId] = new CategoryScopeData { Region = regionDisplay, Location = locationDisplay, - RegionIds = regionIds, - LocationIds = locationIds + RegionIds = collapsed.RegionIds, + LocationIds = collapsed.LocationIds }; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelMultipleOptionAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelMultipleOptionAppService.cs index 90fc93e..913e353 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelMultipleOptionAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelMultipleOptionAppService.cs @@ -43,7 +43,12 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO query = await LabelEntityPartnerScopeHelper.ApplyMultipleOptionPartnerListFilterAsync( _dbContext.SqlSugarClient, query, scopedPartnerIds); - query = LabelEntityListScopeHelper.ApplyMultipleOptionLocationAvailabilityFilter(query, scopedLocationIds); + if (LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter( + input.PartnerId, input.GroupId, input.LocationId)) + { + query = LabelEntityListScopeHelper.ApplyMultipleOptionLocationAvailabilityFilter( + query, scopedLocationIds); + } if (!string.IsNullOrWhiteSpace(input.Sorting)) { @@ -100,11 +105,7 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO var dto = MapToGetOutput(entity); await ApplyPartnerScopeToGetOutputAsync(dto, entity.Id); - if (string.Equals(entity.AvailabilityType, "ALL", StringComparison.OrdinalIgnoreCase)) - { - await ExpandAllScopeIdsToDtoAsync(dto); - } - else if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) { var locationIds = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.MultipleOptionId == entity.Id) @@ -117,9 +118,28 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO dto.GroupIds = regionIds; } + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + dto.PartnerIds, + dto.RegionIds, + dto.LocationIds, + ScopeAllEchoHelper.ForLabelEntityScope( + dto.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType)); + dto.PartnerIds = collapsed.PartnerIds; + dto.CompanyIds = collapsed.PartnerIds; + dto.RegionIds = collapsed.RegionIds; + dto.GroupIds = collapsed.RegionIds; + dto.LocationIds = collapsed.LocationIds; + return dto; } + /// + /// 新增多选项。Company / Region / Location 范围与编辑一致; + /// regionIdsgroupIdslocationIds 可传哨兵 ALL(大小写不敏感),归档为 availabilityType=ALL 且不写门店快照。 + /// public async Task CreateAsync(LabelMultipleOptionCreateInputVo input) { var code = NormalizeOptionCode(input.OptionCode); @@ -129,7 +149,7 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO throw new UserFriendlyException("多选项名称不能为空"); } - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveMultipleOptionScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveMultipleOptionScopeForSaveAsync(input); if (await IsMultipleOptionDuplicatedAsync(code, name, excludeId: null)) { @@ -158,6 +178,7 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO OptionValuesJson = input.OptionValuesJson?.Trim(), State = input.State, AvailabilityType = availabilityType, + AppliedRegionType = appliedRegionType, AppliedPartnerType = partnerScope.AppliedPartnerType, OrderNum = input.OrderNum }; @@ -168,6 +189,10 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO return await GetAsync(entity.Id); } + /// + /// 编辑多选项。适用范围解析与新增共用 ; + /// regionIdsgroupIdslocationIds 可传 ALL,与 GET 回显 ["ALL"] 对称。 + /// public async Task UpdateAsync(string id, LabelMultipleOptionUpdateInputVo input) { var entity = await _dbContext.SqlSugarClient.Queryable() @@ -184,7 +209,7 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO throw new UserFriendlyException("多选项名称不能为空"); } - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveMultipleOptionScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveMultipleOptionScopeForSaveAsync(input); if (await IsMultipleOptionDuplicatedAsync(code, name, excludeId: id)) { @@ -196,6 +221,7 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO entity.OptionValuesJson = input.OptionValuesJson?.Trim(); entity.State = input.State; entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; entity.AppliedPartnerType = partnerScope.AppliedPartnerType; entity.OrderNum = input.OrderNum; entity.LastModificationTime = DateTime.Now; @@ -264,25 +290,7 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO } } - /// AvailabilityType=ALL 时详情回填当前可见 Company/Region/Location 全集,便于 Select All 回显。 - private async Task ExpandAllScopeIdsToDtoAsync(LabelMultipleOptionGetOutputDto dto) - { - var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - var (partners, regions, locations) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, dto.PartnerIds); - if (dto.PartnerIds.Count == 0) - { - dto.PartnerIds = partners; - dto.CompanyIds = partners; - } - - dto.RegionIds = regions; - dto.GroupIds = regions; - dto.LocationIds = locations; - } - - private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, List LocationIds)> ResolveMultipleOptionScopeForSaveAsync( + private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, string AppliedRegionType, List LocationIds)> ResolveMultipleOptionScopeForSaveAsync( LabelMultipleOptionCreateInputVo input) { var partnerScope = await LabelEntityPartnerScopeHelper.ResolvePartnerScopeForSaveAsync( @@ -291,10 +299,8 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO input.PartnerIds, input.CompanyIds); - var regionIds = NormalizeRegionIds(input); - var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds); + var mergedRegionIds = NormalizeRegionIds(input); var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null; - var partnerContext = string.Equals( partnerScope.AppliedPartnerType, LabelEntityPartnerScopeHelper.ScopeSpecified, @@ -302,40 +308,15 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO ? partnerScope.PartnerIds : null; - if (await AllScopeBindingHelper.ShouldTreatMergedLocationScopeAsAllAsync( - _dbContext.SqlSugarClient, - input.AvailabilityType, - regionIds, - explicitLocationIds, - hasScopeArrays, - partnerContext)) - { - return (partnerScope, "ALL", new List()); - } - - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant(); - if (regionIds.Count > 0 || explicitLocationIds.Count > 0) - { - availabilityType = "SPECIFIED"; - } - else if (hasScopeArrays && AllScopeBindingHelper.IsDeclaredAll(availabilityType)) - { - availabilityType = "ALL"; - } - - if (availabilityType != "ALL" && availabilityType != "SPECIFIED") - { - throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); - } - - var locationSpecified = string.Equals(availabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase); - var savedLocationIds = await LocationScopeBindingHelper.ResolveEntityLocationIdsForSaveAsync( + var locScope = await AllScopeBindingHelper.ResolveLabelEntityRegionLocationForSaveAsync( _dbContext.SqlSugarClient, - locationSpecified, - regionIds, - explicitLocationIds); + input.AvailabilityType, + partnerContext, + mergedRegionIds, + input.LocationIds, + hasScopeArrays); - return (partnerScope, locationSpecified ? "SPECIFIED" : "ALL", savedLocationIds); + return (partnerScope, locScope.AvailabilityType, locScope.AppliedRegionType, locScope.LocationIds); } private async Task SaveMultipleOptionPartnerScopeAsync( @@ -467,8 +448,8 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO { Region = AllRegionsDisplay, Location = AllLocationsDisplay, - RegionIds = new List(), - LocationIds = new List() + RegionIds = new List { AllScopeBindingHelper.ScopeAll }, + LocationIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -555,6 +536,10 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); + var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( + _dbContext.SqlSugarClient, locationIds); + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, partnerIds, regionIds, locationIds); result[optionId] = new MultipleOptionScopeData { @@ -564,8 +549,8 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO Location = locationNames.Count > 0 ? string.Join(", ", locationNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) : EmptyDisplay, - RegionIds = regionIds, - LocationIds = locationIds + RegionIds = collapsed.RegionIds, + LocationIds = collapsed.LocationIds }; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs index 9b08e75..4983542 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs @@ -46,7 +46,12 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ .WhereIF(input.State != null, x => x.State == input.State); query = await LabelTemplateScopeHelper.ApplyTemplateScopeFilterAsync( - _dbContext.SqlSugarClient, query, scopedLocationIds); + _dbContext.SqlSugarClient, + query, + scopedLocationIds, + input.PartnerId, + input.GroupId, + input.LocationId); query = LabelTemplateQueryHelper.ApplyListSorting(query, input.Sorting); query = LabelTemplateQueryHelper.ProjectListColumns(query); @@ -216,47 +221,29 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ await FillTemplateScopeOnDtoAsync(dto, template); - // ALL 维度回填当前可见全集 Id,便于编辑页 Select All(与 Label 一致) - var anyAll = - string.Equals(dto.AppliedPartnerType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase) - || string.Equals(dto.AppliedRegionType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase) - || string.Equals(dto.AppliedLocationType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase); - if (anyAll) - { - var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - var preferredPartners = - string.Equals(dto.AppliedPartnerType, LabelTemplateScopeHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase) - ? dto.PartnerIds - : null; - var (partners, regions, locations) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, preferredPartners); - - if (string.Equals(dto.AppliedPartnerType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase) - && dto.PartnerIds.Count == 0) - { - dto.PartnerIds = partners; - dto.CompanyIds = partners; - } - - if (string.Equals(dto.AppliedRegionType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase) - && dto.RegionIds.Count == 0) - { - dto.RegionIds = regions; - dto.GroupIds = regions; - } - - if (string.Equals(dto.AppliedLocationType, LabelTemplateScopeHelper.ScopeAll, StringComparison.OrdinalIgnoreCase) - && dto.LocationIds.Count == 0) - { - dto.LocationIds = locations; - dto.AppliedLocationIds = locations; - } - } + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + dto.PartnerIds, + dto.RegionIds, + dto.LocationIds, + ScopeAllEchoHelper.ForLabelTemplateDimensions( + dto.AppliedPartnerType, + dto.AppliedRegionType, + dto.AppliedLocationType)); + dto.PartnerIds = collapsed.PartnerIds; + dto.CompanyIds = collapsed.PartnerIds; + dto.RegionIds = collapsed.RegionIds; + dto.GroupIds = collapsed.RegionIds; + dto.LocationIds = collapsed.LocationIds; + dto.AppliedLocationIds = collapsed.LocationIds; return dto; } + /// + /// 新增标签模板。Company / Region / Location 三维范围; + /// regionIdsgroupIdslocationIdsappliedLocationIds 可传 ALL 哨兵归档为对应维度 ALL。 + /// [UnitOfWork] public async Task CreateAsync(LabelTemplateCreateInputVo input) { @@ -334,6 +321,12 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ return await GetAsync(code); } + /// + /// 编辑标签模板(版本号 +1)。适用范围经 与 + /// 落库(含主表 AppliedLocationType 及 + /// AppliedPartnerType / AppliedRegionType); + /// regionIdsgroupIdslocationIdsappliedLocationIds 可传 ALL,与 GET 回显对称。 + /// [UnitOfWork] public async Task UpdateAsync(string id, LabelTemplateUpdateInputVo input) { diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTypeAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTypeAppService.cs index ffcf15a..8f93236 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTypeAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTypeAppService.cs @@ -43,7 +43,11 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService query = await LabelEntityPartnerScopeHelper.ApplyTypePartnerListFilterAsync( _dbContext.SqlSugarClient, query, scopedPartnerIds); - query = LabelEntityListScopeHelper.ApplyTypeLocationAvailabilityFilter(query, scopedLocationIds); + if (LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter( + input.PartnerId, input.GroupId, input.LocationId)) + { + query = LabelEntityListScopeHelper.ApplyTypeLocationAvailabilityFilter(query, scopedLocationIds); + } if (!string.IsNullOrWhiteSpace(input.Sorting)) { @@ -106,11 +110,7 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService var dto = MapToGetOutput(entity); await ApplyPartnerScopeToGetOutputAsync(dto, entity.Id); - if (string.Equals(entity.AvailabilityType, "ALL", StringComparison.OrdinalIgnoreCase)) - { - await ExpandAllScopeIdsToDtoAsync(dto); - } - else if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) { var locationIds = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.LabelTypeId == entity.Id) @@ -123,6 +123,21 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService dto.GroupIds = regionIds; } + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + dto.PartnerIds, + dto.RegionIds, + dto.LocationIds, + ScopeAllEchoHelper.ForLabelEntityScope( + dto.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType)); + dto.PartnerIds = collapsed.PartnerIds; + dto.CompanyIds = collapsed.PartnerIds; + dto.RegionIds = collapsed.RegionIds; + dto.GroupIds = collapsed.RegionIds; + dto.LocationIds = collapsed.LocationIds; + return dto; } @@ -135,7 +150,7 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService throw new UserFriendlyException("类型编码和名称不能为空"); } - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveTypeScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveTypeScopeForSaveAsync(input); var duplicated = await _dbContext.SqlSugarClient.Queryable() .AnyAsync(x => !x.IsDeleted && (x.TypeCode == code || x.TypeName == name)); @@ -162,6 +177,7 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService TypeName = name, State = input.State, AvailabilityType = availabilityType, + AppliedRegionType = appliedRegionType, AppliedPartnerType = partnerScope.AppliedPartnerType, OrderNum = input.OrderNum }; @@ -188,7 +204,7 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService throw new UserFriendlyException("类型编码和名称不能为空"); } - var (partnerScope, availabilityType, mergedLocationIds) = await ResolveTypeScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveTypeScopeForSaveAsync(input); var duplicated = await _dbContext.SqlSugarClient.Queryable() .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.TypeCode == code || x.TypeName == name)); @@ -201,6 +217,7 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService entity.TypeName = name; entity.State = input.State; entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; entity.AppliedPartnerType = partnerScope.AppliedPartnerType; entity.OrderNum = input.OrderNum; entity.LastModificationTime = DateTime.Now; @@ -276,25 +293,7 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService } } - /// AvailabilityType=ALL 时详情回填当前可见 Company/Region/Location 全集,便于 Select All 回显。 - private async Task ExpandAllScopeIdsToDtoAsync(LabelTypeGetOutputDto dto) - { - var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - var (partners, regions, locations) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, dto.PartnerIds); - if (dto.PartnerIds.Count == 0) - { - dto.PartnerIds = partners; - dto.CompanyIds = partners; - } - - dto.RegionIds = regions; - dto.GroupIds = regions; - dto.LocationIds = locations; - } - - private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, List LocationIds)> ResolveTypeScopeForSaveAsync( + private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, string AppliedRegionType, List LocationIds)> ResolveTypeScopeForSaveAsync( LabelTypeCreateInputVo input) { var partnerScope = await LabelEntityPartnerScopeHelper.ResolvePartnerScopeForSaveAsync( @@ -303,10 +302,8 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService input.PartnerIds, input.CompanyIds); - var regionIds = NormalizeRegionIds(input); - var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds); + var mergedRegionIds = NormalizeRegionIds(input); var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null; - var partnerContext = string.Equals( partnerScope.AppliedPartnerType, LabelEntityPartnerScopeHelper.ScopeSpecified, @@ -314,40 +311,15 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService ? partnerScope.PartnerIds : null; - if (await AllScopeBindingHelper.ShouldTreatMergedLocationScopeAsAllAsync( - _dbContext.SqlSugarClient, - input.AvailabilityType, - regionIds, - explicitLocationIds, - hasScopeArrays, - partnerContext)) - { - return (partnerScope, "ALL", new List()); - } - - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant(); - if (regionIds.Count > 0 || explicitLocationIds.Count > 0) - { - availabilityType = "SPECIFIED"; - } - else if (hasScopeArrays && AllScopeBindingHelper.IsDeclaredAll(availabilityType)) - { - availabilityType = "ALL"; - } - - if (availabilityType != "ALL" && availabilityType != "SPECIFIED") - { - throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); - } - - var locationSpecified = string.Equals(availabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase); - var savedLocationIds = await LocationScopeBindingHelper.ResolveEntityLocationIdsForSaveAsync( + var locScope = await AllScopeBindingHelper.ResolveLabelEntityRegionLocationForSaveAsync( _dbContext.SqlSugarClient, - locationSpecified, - regionIds, - explicitLocationIds); + input.AvailabilityType, + partnerContext, + mergedRegionIds, + input.LocationIds, + hasScopeArrays); - return (partnerScope, locationSpecified ? "SPECIFIED" : "ALL", savedLocationIds); + return (partnerScope, locScope.AvailabilityType, locScope.AppliedRegionType, locScope.LocationIds); } private async Task SaveTypePartnerScopeAsync( @@ -475,8 +447,8 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService { Region = AllRegionsDisplay, Location = AllLocationsDisplay, - RegionIds = new List(), - LocationIds = new List() + RegionIds = new List { AllScopeBindingHelper.ScopeAll }, + LocationIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -563,6 +535,10 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); + var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( + _dbContext.SqlSugarClient, locationIds); + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, partnerIds, regionIds, locationIds); result[typeId] = new TypeScopeData { @@ -572,8 +548,8 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService Location = locationNames.Count > 0 ? string.Join(", ", locationNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) : EmptyDisplay, - RegionIds = regionIds, - LocationIds = locationIds + RegionIds = collapsed.RegionIds, + LocationIds = collapsed.LocationIds }; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs index a16f90f..e7461e0 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs @@ -117,7 +117,9 @@ public class ProductAppService : ApplicationService, IProductAppService State = x.State, AvailabilityType = x.AvailabilityType, NoOfLabels = countMap.TryGetValue(x.Id, out var count) ? count : 0, - LocationIds = isAllLocations ? new List() : (locationIds ?? new List()), + LocationIds = isAllLocations + ? new List { AllScopeBindingHelper.ScopeAll } + : (locationIds ?? new List()), LocationName = isAllLocations ? AllScopeBindingHelper.AllLocationsDisplay : (string.IsNullOrWhiteSpace(locationName) ? FoodLabelingDisplayConsts.NotAvailable : locationName!) @@ -172,8 +174,12 @@ public class ProductAppService : ApplicationService, IProductAppService { var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( CurrentUser, _dbContext, null, null, null); - (partnerIds, groupIds, locationIds) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, null); + partnerIds = scoped is { Count: > 0 } + ? await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( + _dbContext.SqlSugarClient, scoped) + : new List(); + groupIds = new List { AllScopeBindingHelper.ScopeAll }; + locationIds = new List { AllScopeBindingHelper.ScopeAll }; } else { @@ -181,6 +187,29 @@ public class ProductAppService : ApplicationService, IProductAppService _dbContext.SqlSugarClient, locationIds); groupIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); + (_, groupIds, locationIds) = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + partnerIds, + groupIds, + locationIds, + new ScopeAllEchoHelper.ScopeAllEchoOptions + { + IsRegionAll = AllScopeBindingHelper.IsDeclaredAll(entity.AppliedRegionType), + IsLocationAll = false + }); + } + + List companyIdsForEcho; + if (isAllLocations) + { + // 产品 Company 不支持 ALL:全部门店时不回显 companyIds 哨兵 + companyIdsForEcho = new List(); + } + else + { + companyIdsForEcho = partnerIds.Count > 0 + ? new List { partnerIds[0] } + : new List(); } return new ProductGetOutputDto @@ -197,9 +226,11 @@ public class ProductAppService : ApplicationService, IProductAppService CategoryPhotoUrl = entity.CategoryPhotoUrl, State = entity.State, AvailabilityType = entity.AvailabilityType, - PartnerId = partnerIds.Count > 0 ? partnerIds[0] : null, + PartnerId = companyIdsForEcho.Count > 0 ? companyIdsForEcho[0] : null, PartnerIds = partnerIds, + CompanyIds = companyIdsForEcho, GroupIds = groupIds, + RegionIds = groupIds, LocationIds = locationIds }; } @@ -242,8 +273,9 @@ public class ProductAppService : ApplicationService, IProductAppService }; ApplyProductAppearanceToEntity(entity, input); - var (availabilityType, locationIds) = await ResolveProductScopeForSaveAsync(input); + var (availabilityType, appliedRegionType, locationIds) = await ResolveProductScopeForSaveAsync(input); entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); @@ -303,8 +335,9 @@ public class ProductAppService : ApplicationService, IProductAppService entity.State = input.State; ApplyProductAppearanceToEntity(entity, input); - var (availabilityType, locationIds) = await ResolveProductScopeForSaveAsync(input); + var (availabilityType, appliedRegionType, locationIds) = await ResolveProductScopeForSaveAsync(input); entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); @@ -592,11 +625,27 @@ public class ProductAppService : ApplicationService, IProductAppService input.PartnerId, input.GroupId, input.LocationId); + + var partnerOnly = !LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter( + input.PartnerId, input.GroupId, input.LocationId); + if (locationIds is not null) { if (locationIds.Count == 0) { - query = query.Where(_ => false); + // 仅 PartnerId 且该公司暂无门店:保留 AvailabilityType=ALL + if (partnerOnly) + { + var hasAvailabilityColumn = await ProductScopeSchemaHelper.HasAvailabilityTypeColumnAsync( + _dbContext.SqlSugarClient); + query = hasAvailabilityColumn + ? query.Where(p => p.AvailabilityType == AllScopeBindingHelper.ScopeAll) + : query.Where(_ => false); + } + else + { + query = query.Where(_ => false); + } } else { @@ -639,6 +688,11 @@ public class ProductAppService : ApplicationService, IProductAppService /// private async Task EnsureProductVisibleToCurrentUserAsync(FlProductDbEntity entity) { + if (!CurrentUser.Id.HasValue) + { + throw new UserFriendlyException("登录已过期,请重新登录"); + } + if (ReportsRoleHelper.IsAdminRole(CurrentUser)) { return; @@ -917,53 +971,230 @@ public class ProductAppService : ApplicationService, IProductAppService private static bool HasProductScopeBinding(ProductCreateInputVo input) => !string.IsNullOrWhiteSpace(input.PartnerId) || + input.CompanyIds is not null || input.GroupIds is not null || + input.RegionIds is not null || input.LocationIds is not null; private static bool ShouldPersistProductScope(ProductCreateInputVo input) => HasProductScopeBinding(input) || !string.IsNullOrWhiteSpace(input.AvailabilityType); /// - /// 解析产品门店范围:ALL 时不写 fl_location_product 快照,后续新增门店自动可见。 + /// 合并入参 Region(regionIds / groupIds)。 + /// + private static List NormalizeProductRegionIds(ProductCreateInputVo input) + { + var merged = new HashSet(StringComparer.Ordinal); + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.RegionIds)) + { + merged.Add(id); + } + + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.GroupIds)) + { + merged.Add(id); + } + + return merged.OrderBy(x => x, StringComparer.Ordinal).ToList(); + } + + /// + /// 解析产品 Company:仅支持单选具体 Guid;不支持 ALL。合并 partnerIdcompanyIds。 /// - private async Task<(string AvailabilityType, List LocationIds)> ResolveProductScopeForSaveAsync( + private static string? ResolveSinglePartnerIdForSave(ProductCreateInputVo input) + { + var companyIds = LocationScopeBindingHelper.NormalizeIds(input.CompanyIds); + if (AllScopeBindingHelper.HasAllScopeSentinelSelection(companyIds) + || LocationScopeBindingHelper.IsAllScopeSentinel(input.PartnerId)) + { + throw new UserFriendlyException("产品适用 Company 不支持 ALL,请传单个具体 Company Id"); + } + + var concreteCompanyIds = LocationScopeBindingHelper.FilterConcreteScopeIds(companyIds); + if (concreteCompanyIds.Count > 1) + { + throw new UserFriendlyException("产品适用 Company 仅支持单选(companyIds 最多传 1 个)"); + } + + var fromCompanyIds = concreteCompanyIds.Count == 1 ? concreteCompanyIds[0] : null; + var fromPartnerId = input.PartnerId?.Trim(); + if (string.IsNullOrWhiteSpace(fromPartnerId)) + { + return fromCompanyIds; + } + + if (!string.IsNullOrWhiteSpace(fromCompanyIds) + && !string.Equals(fromPartnerId, fromCompanyIds, StringComparison.OrdinalIgnoreCase)) + { + throw new UserFriendlyException("partnerId 与 companyIds 不一致"); + } + + return fromPartnerId; + } + + /// + /// 解析产品门店范围:全局 ALL 不写快照;范围内 ALL(已指定 Company/Region)展开为 SPECIFIED 快照。 + /// + private async Task<(string AvailabilityType, string AppliedRegionType, List LocationIds)> ResolveProductScopeForSaveAsync( ProductCreateInputVo input) { if (!ShouldPersistProductScope(input)) { - return (AllScopeBindingHelper.ScopeSpecified, new List()); + return (AllScopeBindingHelper.ScopeSpecified, AllScopeBindingHelper.ScopeAll, new List()); + } + + var partnerId = ResolveSinglePartnerIdForSave(input); + var normalizedLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds); + var normalizedGroupIds = NormalizeProductRegionIds(input); + var locationHasAll = AllScopeBindingHelper.HasAllScopeSentinelSelection(normalizedLocationIds); + var regionHasAllSentinel = AllScopeBindingHelper.HasAllScopeSentinelSelection(normalizedGroupIds); + var groupIds = LocationScopeBindingHelper.FilterConcreteScopeIds(normalizedGroupIds); + var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(normalizedLocationIds); + var hasConcretePartner = !string.IsNullOrWhiteSpace(partnerId); + var partnerContext = hasConcretePartner ? new[] { partnerId! } : null; + + var regionHasAll = regionHasAllSentinel; + if (!regionHasAll && groupIds.Count > 0) + { + var allRegions = await AllScopeBindingHelper.ResolveAllRegionIdsAsync( + _dbContext.SqlSugarClient, partnerContext); + regionHasAll = allRegions.Count > 0 + && AllScopeBindingHelper.IsFullIdSelection(groupIds, allRegions); + } + + // Location ALL 哨兵,或具体 Region 下空选/全选门店(前端常展开为 Guid) + var locationCoversConcreteRegions = false; + if (!locationHasAll && groupIds.Count > 0 && !regionHasAll) + { + if (explicitLocationIds.Count == 0) + { + locationCoversConcreteRegions = true; + } + else + { + var regionUniverse = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( + _dbContext.SqlSugarClient, partnerContext, groupIds); + locationCoversConcreteRegions = regionUniverse.Count > 0 + && AllScopeBindingHelper.IsFullIdSelection( + explicitLocationIds, regionUniverse); + } + } + + if (locationHasAll || locationCoversConcreteRegions) + { + var expandRegions = regionHasAll ? null : (groupIds.Count > 0 ? groupIds : null); + var expanded = await LocationScopeBindingHelper.ExpandScopedAllLocationsForSaveAsync( + _dbContext.SqlSugarClient, + partnerContext, + expandRegions); + if (expanded is not null) + { + return ( + AllScopeBindingHelper.ScopeSpecified, + regionHasAll || groupIds.Count == 0 + ? AllScopeBindingHelper.ScopeAll + : AllScopeBindingHelper.ScopeSpecified, + expanded); + } + + return (AllScopeBindingHelper.ScopeAll, AllScopeBindingHelper.ScopeAll, new List()); + } + + // Region=ALL + 具体门店:保留门店快照 + if (regionHasAll && explicitLocationIds.Count > 0) + { + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync( + _dbContext.SqlSugarClient, explicitLocationIds); + return ( + AllScopeBindingHelper.ScopeSpecified, + AllScopeBindingHelper.ScopeAll, + explicitLocationIds); + } + + if (explicitLocationIds.Count > 0) + { + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync( + _dbContext.SqlSugarClient, explicitLocationIds); + return ( + AllScopeBindingHelper.ScopeSpecified, + AllScopeBindingHelper.ScopeSpecified, + explicitLocationIds); + } + + if (regionHasAll) + { + var expanded = await LocationScopeBindingHelper.ExpandScopedAllLocationsForSaveAsync( + _dbContext.SqlSugarClient, + partnerContext, + regionIds: null); + if (expanded is not null) + { + return ( + AllScopeBindingHelper.ScopeSpecified, + AllScopeBindingHelper.ScopeAll, + expanded); + } + + return (AllScopeBindingHelper.ScopeAll, AllScopeBindingHelper.ScopeAll, new List()); } if (await AllScopeBindingHelper.ShouldTreatProductLocationScopeAsAllAsync( _dbContext.SqlSugarClient, input.AvailabilityType, - input.PartnerId, - input.GroupIds, - input.LocationIds, + partnerId, + groupIds, + explicitLocationIds, HasProductScopeBinding(input))) { - return (AllScopeBindingHelper.ScopeAll, new List()); + return (AllScopeBindingHelper.ScopeAll, AllScopeBindingHelper.ScopeAll, new List()); } if (!HasProductScopeBinding(input)) { - return (AllScopeBindingHelper.ScopeSpecified, new List()); + return (AllScopeBindingHelper.ScopeSpecified, AllScopeBindingHelper.ScopeAll, new List()); } - var locIds = await ResolveProductLocationIdsForSaveAsync(input); - return (AllScopeBindingHelper.ScopeSpecified, locIds); + var locIds = await ResolveProductLocationIdsForSaveAsync(partnerId, groupIds, explicitLocationIds); + return ( + AllScopeBindingHelper.ScopeSpecified, + groupIds.Count > 0 ? AllScopeBindingHelper.ScopeSpecified : AllScopeBindingHelper.ScopeAll, + locIds); } /// /// 合并 Company(partnerId)、Region(groupIds)、门店(locationIds)并校验存在性。 + /// 有具体门店时以门店为准,不再与 Region/Company 并集。 /// - private async Task> ResolveProductLocationIdsForSaveAsync(ProductCreateInputVo input) + private async Task> ResolveProductLocationIdsForSaveAsync( + string? partnerId, + IReadOnlyList groupIds, + IReadOnlyList locationIds) { + if (LocationScopeBindingHelper.IsAllScopeSentinel(partnerId) + || AllScopeBindingHelper.HasAllScopeSentinelSelection(groupIds) + || AllScopeBindingHelper.HasAllScopeSentinelSelection(locationIds)) + { + throw new UserFriendlyException("适用范围为全选(ALL),无需解析具体门店"); + } + + var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds); + if (concreteLocations.Count > 0) + { + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync( + _dbContext.SqlSugarClient, concreteLocations); + return concreteLocations; + } + var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( _dbContext.SqlSugarClient, - input.PartnerId, - input.GroupIds, - input.LocationIds); + partnerId, + groupIds, + locationIds); + if (merged.Count == 0) + { + throw new UserFriendlyException("指定适用 Company、Region 或门店时,至少需要匹配到一个有效门店"); + } + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged); return merged; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs index d80a243..c614dbe 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs @@ -77,11 +77,17 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total); - var scopeMap = await BuildCategoryScopeMapAsync(entities); + var ids = entities.Select(x => x.Id).ToList(); + var partnerScopeMap = await LabelEntityPartnerScopeHelper.BuildPartnerScopeDisplayMapAsync( + _dbContext.SqlSugarClient, + LabelEntityPartnerScopeHelper.LabelEntityPartnerKind.ProductCategory, + ids); + var scopeMap = await BuildCategoryScopeMapAsync(entities, partnerScopeMap); var items = entities.Select(x => { scopeMap.TryGetValue(x.Id, out var scope); + partnerScopeMap.TryGetValue(x.Id, out var partnerScope); return new ProductCategoryGetListOutputDto { Id = x.Id, @@ -92,6 +98,10 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp ButtonAppearance = x.ButtonAppearance, State = x.State, AvailabilityType = x.AvailabilityType, + AppliedPartnerType = partnerScope?.AppliedPartnerType ?? LabelEntityPartnerScopeHelper.ScopeAll, + Company = partnerScope?.Company ?? LabelEntityPartnerScopeHelper.AllCompaniesDisplay, + PartnerIds = partnerScope?.PartnerIds ?? new List(), + CompanyIds = partnerScope?.PartnerIds ?? new List(), OrderNum = x.OrderNum, LastEdited = x.LastModificationTime ?? x.CreationTime, Region = scope?.Region ?? string.Empty, @@ -119,17 +129,8 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp await EnsureCategoryVisibleToCurrentUserAsync(entity); var dto = MapToGetOutput(entity); - if (string.Equals(entity.AvailabilityType, "ALL", StringComparison.OrdinalIgnoreCase)) - { - var scoped = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( - CurrentUser, _dbContext, null, null, null); - var (_, regions, locations) = await AllScopeBindingHelper.ResolveDisplayIdsForAllScopeAsync( - _dbContext.SqlSugarClient, scoped, null); - dto.RegionIds = regions; - dto.GroupIds = regions; - dto.LocationIds = locations; - } - else if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) + await ApplyPartnerScopeToGetOutputAsync(dto, entity.Id); + if (string.Equals(entity.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)) { var locationIds = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.CategoryId == entity.Id) @@ -142,6 +143,21 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp dto.GroupIds = regionIds; } + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + dto.PartnerIds, + dto.RegionIds, + dto.LocationIds, + ScopeAllEchoHelper.ForLabelEntityScope( + dto.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType)); + dto.PartnerIds = collapsed.PartnerIds; + dto.CompanyIds = collapsed.PartnerIds; + dto.RegionIds = collapsed.RegionIds; + dto.GroupIds = collapsed.RegionIds; + dto.LocationIds = collapsed.LocationIds; + return dto; } @@ -159,7 +175,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp var displayText = input.DisplayText?.Trim(); var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); - var (availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); await EnsureCategoryNotDuplicatedAsync(code, name); await PurgeSoftDeletedProductCategoriesByCodeAsync(code); @@ -182,10 +198,13 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp ButtonAppearance = appearance, State = input.State, AvailabilityType = availabilityType, + AppliedRegionType = appliedRegionType, + AppliedPartnerType = partnerScope.AppliedPartnerType, OrderNum = input.OrderNum }; await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); + await SaveCategoryPartnerScopeAsync(entity.Id, partnerScope, currentUserId, now); await SaveCategoryLocationsAsync(entity.Id, availabilityType, mergedLocationIds, currentUserId, now); return await GetAsync(entity.Id); } @@ -213,7 +232,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp var displayText = input.DisplayText?.Trim(); var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); - var (availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); + var (partnerScope, availabilityType, appliedRegionType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input); await EnsureCategoryNotDuplicatedAsync(code, name, id); @@ -224,11 +243,15 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp entity.ButtonAppearance = appearance; entity.State = input.State; entity.AvailabilityType = availabilityType; + entity.AppliedRegionType = appliedRegionType; + entity.AppliedPartnerType = partnerScope.AppliedPartnerType; entity.OrderNum = input.OrderNum; entity.LastModificationTime = DateTime.Now; entity.LastModifierId = CurrentUser?.Id?.ToString(); await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); + await SaveCategoryPartnerScopeAsync(entity.Id, partnerScope, entity.LastModifierId, + entity.LastModificationTime ?? DateTime.Now); await SaveCategoryLocationsAsync(entity.Id, availabilityType, mergedLocationIds, entity.LastModifierId, entity.LastModificationTime ?? DateTime.Now); return await GetAsync(id); @@ -274,64 +297,38 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp ButtonAppearance = x.ButtonAppearance, State = x.State, AvailabilityType = x.AvailabilityType, + AppliedPartnerType = x.AppliedPartnerType, OrderNum = x.OrderNum }; } - private async Task<(string AvailabilityType, List LocationIds)> ResolveCategoryScopeForSaveAsync( + private async Task<(LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult PartnerScope, string AvailabilityType, string AppliedRegionType, List LocationIds)> ResolveCategoryScopeForSaveAsync( ProductCategoryCreateInputVo input) { - var regionIds = NormalizeRegionIds(input); - var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds); - var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null; - - // 由 Region/门店反推 Company,使「公司下 Select All」可归档为 ALL(勿用全系统门店作全集) - var partnerContext = await AllScopeBindingHelper.ResolvePartnerContextFromScopeAsync( + var partnerScope = await LabelEntityPartnerScopeHelper.ResolvePartnerScopeForSaveAsync( _dbContext.SqlSugarClient, - null, - regionIds, - explicitLocationIds); + input.AppliedPartnerType, + input.PartnerIds, + input.CompanyIds); - if (await AllScopeBindingHelper.ShouldTreatMergedLocationScopeAsAllAsync( - _dbContext.SqlSugarClient, - input.AvailabilityType, - regionIds, - explicitLocationIds, - hasScopeArrays, - partnerContext)) - { - return ("ALL", new List()); - } - - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant(); - if (regionIds.Count > 0 || explicitLocationIds.Count > 0) - { - availabilityType = "SPECIFIED"; - } - else if (hasScopeArrays && AllScopeBindingHelper.IsDeclaredAll(availabilityType)) - { - availabilityType = "ALL"; - } - - if (availabilityType != "ALL" && availabilityType != "SPECIFIED") - { - throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); - } - - if (availabilityType == "ALL") - { - return ("ALL", new List()); - } - - var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( - _dbContext.SqlSugarClient, (IReadOnlyList?)null, regionIds, explicitLocationIds); - if (merged.Count == 0) - { - throw new UserFriendlyException("指定适用区域或门店时,至少需要匹配到一个有效门店"); - } + var mergedRegionIds = NormalizeRegionIds(input); + var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null; + var partnerContext = string.Equals( + partnerScope.AppliedPartnerType, + LabelEntityPartnerScopeHelper.ScopeSpecified, + StringComparison.OrdinalIgnoreCase) + ? partnerScope.PartnerIds + : null; + + var locScope = await AllScopeBindingHelper.ResolveLabelEntityRegionLocationForSaveAsync( + _dbContext.SqlSugarClient, + input.AvailabilityType, + partnerContext, + mergedRegionIds, + input.LocationIds, + hasScopeArrays); - await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged); - return ("SPECIFIED", merged); + return (partnerScope, locScope.AvailabilityType, locScope.AppliedRegionType, locScope.LocationIds); } /// @@ -361,6 +358,18 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp { var scopedLocationIds = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync( CurrentUser, _dbContext, partnerId, groupId, locationId); + var scopedPartnerIds = await LabelEntityPartnerScopeHelper.ResolveListPartnerIdsAsync( + _dbContext.SqlSugarClient, partnerId, scopedLocationIds); + + query = await LabelEntityPartnerScopeHelper.ApplyProductCategoryPartnerListFilterAsync( + _dbContext.SqlSugarClient, query, scopedPartnerIds); + + // 仅 PartnerId:只按 Company 维度筛,避免第二家公司无门店时误杀 + if (!LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter(partnerId, groupId, locationId)) + { + return query; + } + if (scopedLocationIds is null) { return query; @@ -368,10 +377,10 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp if (scopedLocationIds.Count == 0) { - return query.Where(_ => false); + return query.Where(c => c.AvailabilityType == "ALL"); } - // 非平台管理员:绑定门店与可见范围有交集,或 AvailabilityType=ALL(动态包含后续新增门店) + // 绑定门店与可见范围有交集,或 AvailabilityType=ALL(动态包含后续新增门店) return query.Where(c => c.AvailabilityType == "ALL" || SqlFunc.Subqueryable() @@ -414,6 +423,39 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp } } + private async Task SaveCategoryPartnerScopeAsync( + string categoryId, + LabelEntityPartnerScopeHelper.LabelEntityPartnerScopeSaveResult partnerScope, + string? currentUserId, + DateTime now) + { + await LabelEntityPartnerScopeHelper.SavePartnerScopeAsync( + _dbContext.SqlSugarClient, + _guidGenerator, + LabelEntityPartnerScopeHelper.LabelEntityPartnerKind.ProductCategory, + categoryId, + partnerScope, + currentUserId, + now); + } + + private async Task ApplyPartnerScopeToGetOutputAsync(ProductCategoryGetOutputDto dto, string entityId) + { + var map = await LabelEntityPartnerScopeHelper.BuildPartnerScopeDisplayMapAsync( + _dbContext.SqlSugarClient, + LabelEntityPartnerScopeHelper.LabelEntityPartnerKind.ProductCategory, + new[] { entityId }); + if (!map.TryGetValue(entityId, out var scope)) + { + return; + } + + dto.AppliedPartnerType = scope.AppliedPartnerType; + dto.Company = scope.Company; + dto.PartnerIds = scope.PartnerIds; + dto.CompanyIds = scope.PartnerIds; + } + private async Task SaveCategoryLocationsAsync( string categoryId, string availabilityType, @@ -455,7 +497,8 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp /// 列表行:Region/Location 展示文案 + 多选 Id 数组(编辑回显)。 /// private async Task> BuildCategoryScopeMapAsync( - List entities) + List entities, + Dictionary partnerScopeMap) { var result = new Dictionary(StringComparer.Ordinal); if (entities.Count == 0) @@ -463,15 +506,16 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp return result; } - foreach (var e in entities.Where(x => - !string.Equals(x.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase))) + var entityById = entities.ToDictionary(x => x.Id, StringComparer.Ordinal); + + foreach (var e in entities.Where(x => AllScopeBindingHelper.IsDeclaredAll(x.AvailabilityType))) { result[e.Id] = new CategoryScopeData { Region = AllRegionsDisplay, Location = AllLocationsDisplay, - RegionIds = new List(), - LocationIds = new List() + RegionIds = new List { AllScopeBindingHelper.ScopeAll }, + LocationIds = new List { AllScopeBindingHelper.ScopeAll } }; } @@ -560,16 +604,43 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); + entityById.TryGetValue(catId, out var entity); + partnerScopeMap.TryGetValue(catId, out var partnerScope); + var partnerContext = entity is not null + && string.Equals( + entity.AppliedPartnerType, + LabelEntityPartnerScopeHelper.ScopeSpecified, + StringComparison.OrdinalIgnoreCase) + ? partnerScope?.PartnerIds + : null; + + var (regionDisplay, locationDisplay) = await EntityLocationScopeDisplayHelper.BuildListDisplayAsync( + _dbContext.SqlSugarClient, + entity?.AvailabilityType, + regionIds, + locationIds, + regions, + locationNames, + partnerContext, + entity?.AppliedRegionType); + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync( + _dbContext.SqlSugarClient, + partnerContext, + regionIds, + locationIds, + entity is not null + ? ScopeAllEchoHelper.ForLabelEntityScope( + entity.AppliedPartnerType, + entity.AppliedRegionType, + entity.AvailabilityType) + : null); + result[catId] = new CategoryScopeData { - Region = regions.Count > 0 - ? string.Join(", ", regions.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) - : EmptyDisplay, - Location = locationNames.Count > 0 - ? string.Join(", ", locationNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) - : EmptyDisplay, - RegionIds = regionIds, - LocationIds = locationIds + Region = regionDisplay, + Location = locationDisplay, + RegionIds = collapsed.RegionIds, + LocationIds = collapsed.LocationIds }; } @@ -631,6 +702,10 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp foreach (var row in softDeleted) { + await LabelEntityPartnerScopeHelper.DeletePartnerScopeRowsAsync( + _dbContext.SqlSugarClient, + LabelEntityPartnerScopeHelper.LabelEntityPartnerKind.ProductCategory, + row.Id); await _dbContext.SqlSugarClient.Deleteable() .Where(x => x.CategoryId == row.Id) .ExecuteCommandAsync(); diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductLocationAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductLocationAppService.cs index fab9bac..0bec691 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductLocationAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductLocationAppService.cs @@ -33,40 +33,127 @@ public class ProductLocationAppService : ApplicationService, IProductLocationApp var locationId = input.LocationId?.Trim(); var productId = input.ProductId?.Trim(); + var partnerId = input.PartnerId?.Trim(); - var query = _dbContext.SqlSugarClient - .Queryable((lp, p) => lp.ProductId == p.Id) - .Where((lp, p) => p.IsDeleted == false); + List entities; + var hasAvailabilityColumn = await ProductScopeSchemaHelper.HasAvailabilityTypeColumnAsync( + _dbContext.SqlSugarClient); if (!string.IsNullOrWhiteSpace(locationId)) { - query = query.Where((lp, p) => lp.LocationId == locationId); + // 指定门店:SPECIFIED 关联行 ∪ AvailabilityType=ALL 的产品 + var query = _dbContext.SqlSugarClient + .Queryable() + .LeftJoin((p, lp) => + p.Id == lp.ProductId && lp.LocationId == locationId) + .Where((p, lp) => !p.IsDeleted) + .WhereIF(!string.IsNullOrWhiteSpace(productId), (p, lp) => p.Id == productId); + + if (hasAvailabilityColumn) + { + query = query.Where((p, lp) => + p.AvailabilityType == AllScopeBindingHelper.ScopeAll + || lp.Id != null); + } + else + { + query = query.Where((p, lp) => lp.Id != null); + } + + query = string.IsNullOrWhiteSpace(input.Sorting) + ? query.OrderBy((p, lp) => p.ProductName) + : query.OrderBy(input.Sorting); + + entities = await query + .Select((p, lp) => new ProductLocationGetListOutputDto + { + Id = lp.Id ?? p.Id, + LocationId = locationId, + ProductId = p.Id, + ProductCode = p.ProductCode, + ProductName = p.ProductName, + ProductImageUrl = p.ProductImageUrl, + LocationCode = null, + LocationName = null + }) + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total); } + else if (!string.IsNullOrWhiteSpace(partnerId)) + { + // 仅 PartnerId:该公司下门店关联 ∪ AvailabilityType=ALL + var partnerLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync( + _dbContext.SqlSugarClient, new[] { partnerId }); + + var query = _dbContext.SqlSugarClient + .Queryable() + .LeftJoin((p, lp) => + p.Id == lp.ProductId + && partnerLocationIds.Count > 0 + && partnerLocationIds.Contains(lp.LocationId)) + .Where((p, lp) => !p.IsDeleted) + .WhereIF(!string.IsNullOrWhiteSpace(productId), (p, lp) => p.Id == productId); + + if (hasAvailabilityColumn) + { + query = query.Where((p, lp) => + p.AvailabilityType == AllScopeBindingHelper.ScopeAll + || lp.Id != null); + } + else if (partnerLocationIds.Count == 0) + { + query = query.Where(_ => false); + } + else + { + query = query.Where((p, lp) => lp.Id != null); + } - if (!string.IsNullOrWhiteSpace(productId)) + query = string.IsNullOrWhiteSpace(input.Sorting) + ? query.OrderBy((p, lp) => p.ProductName) + : query.OrderBy(input.Sorting); + + // 按产品去重分页(ALL 产品无关联行时 LocationId 为空串) + entities = await query + .Select((p, lp) => new ProductLocationGetListOutputDto + { + Id = lp.Id ?? p.Id, + LocationId = lp.LocationId ?? string.Empty, + ProductId = p.Id, + ProductCode = p.ProductCode, + ProductName = p.ProductName, + ProductImageUrl = p.ProductImageUrl, + LocationCode = null, + LocationName = null + }) + .Distinct() + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total); + } + else { - query = query.Where((lp, p) => lp.ProductId == productId); + var query = _dbContext.SqlSugarClient + .Queryable((lp, p) => lp.ProductId == p.Id) + .Where((lp, p) => p.IsDeleted == false) + .WhereIF(!string.IsNullOrWhiteSpace(productId), (lp, p) => lp.ProductId == productId); + + query = string.IsNullOrWhiteSpace(input.Sorting) + ? query.OrderBy((lp, p) => p.ProductName) + : query.OrderBy(input.Sorting); + + entities = await query + .Select((lp, p) => new ProductLocationGetListOutputDto + { + Id = lp.Id, + LocationId = lp.LocationId, + ProductId = p.Id, + ProductCode = p.ProductCode, + ProductName = p.ProductName, + ProductImageUrl = p.ProductImageUrl, + LocationCode = null, + LocationName = null + }) + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total); } - // 默认排序 - query = string.IsNullOrWhiteSpace(input.Sorting) - ? query.OrderBy((lp, p) => p.ProductName) - : query.OrderBy(input.Sorting); - - var entities = await query - .Select((lp, p) => new ProductLocationGetListOutputDto - { - Id = lp.Id, - LocationId = lp.LocationId, - ProductId = p.Id, - ProductCode = p.ProductCode, - ProductName = p.ProductName, - ProductImageUrl = p.ProductImageUrl, - LocationCode = null, - LocationName = null - }) - .ToPageListAsync(input.SkipCount, input.MaxResultCount, total); - // 拉取门店信息用于输出 var locationIdSet = entities .Select(x => x.LocationId) @@ -125,13 +212,31 @@ public class ProductLocationAppService : ApplicationService, IProductLocationApp throw new UserFriendlyException("门店Id不能为空"); } - var rows = await _dbContext.SqlSugarClient - .Queryable((lp, p) => lp.ProductId == p.Id) - .Where((lp, p) => lp.LocationId == locationId && !p.IsDeleted) - .Select((lp, p) => new ProductLocationGetListOutputDto + var hasAvailabilityColumn = await ProductScopeSchemaHelper.HasAvailabilityTypeColumnAsync( + _dbContext.SqlSugarClient); + + var query = _dbContext.SqlSugarClient + .Queryable() + .LeftJoin((p, lp) => + p.Id == lp.ProductId && lp.LocationId == locationId) + .Where((p, lp) => !p.IsDeleted); + + if (hasAvailabilityColumn) + { + query = query.Where((p, lp) => + p.AvailabilityType == AllScopeBindingHelper.ScopeAll + || lp.Id != null); + } + else + { + query = query.Where((p, lp) => lp.Id != null); + } + + var rows = await query + .Select((p, lp) => new ProductLocationGetListOutputDto { - Id = lp.Id, - LocationId = lp.LocationId, + Id = lp.Id ?? p.Id, + LocationId = locationId, ProductId = p.Id, ProductCode = p.ProductCode, ProductName = p.ProductName, diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs index 4c7920c..37fa92b 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs @@ -138,10 +138,17 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService scopeLocationIds = assigned.Select(x => x.Id).ToList(); } - // 库中存展开 Guid;全选时编辑回显折叠为 ["ALL"],与新增传 ALL 对称 + var dim = await LoadTeamMemberScopeAsync(id); + // 库中存展开 Guid;结合 Applied* 维度回显 ["ALL"],与新增传 ALL 对称 (regionIds, scopeLocationIds, assigned) = await TeamMemberScopeDisplayHelper.CollapseScopeIdsToAllSentinelForEditAsync( - _dbContext.SqlSugarClient, partnerIds, regionIds, scopeLocationIds, assigned); + _dbContext.SqlSugarClient, + partnerIds, + regionIds, + scopeLocationIds, + assigned, + dim.AppliedRegionType, + dim.AppliedLocationType); return new TeamMemberGetOutputDto { @@ -219,7 +226,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService /// 服务器错误 public async Task CreateAsync(TeamMemberCreateInputVo input) { - var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input, input.RoleId); + var scope = await ResolveTeamMemberScopeForSaveAsync(input, input.RoleId); var user = new UserAggregateRoot { @@ -242,7 +249,8 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService await _userManager.GiveUserSetRoleAsync(new List { user.Id }, new List { input.RoleId.Value }); } - await UpsertUserLocationsAsync(user.Id, mergedLocationIds); + await UpsertUserLocationsAsync(user.Id, scope.LocationIds); + await UpsertTeamMemberScopeAsync(user.Id, scope.AppliedRegionType, scope.AppliedLocationType); return await GetAsync(user.Id); } @@ -288,7 +296,17 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService /// 服务器错误 public async Task UpdateAsync(Guid id, TeamMemberUpdateInputVo input) { - var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input); + var scope = await ResolveTeamMemberScopeForSaveAsync( + new TeamMemberCreateInputVo + { + PartnerId = input.PartnerId, + PartnerIds = input.PartnerIds, + RegionIds = input.RegionIds, + GroupIds = input.GroupIds, + LocationIds = input.LocationIds, + Locations = input.Locations + }, + input.RoleId); var user = await _userRepository.GetByIdAsync(id); if (user is null || user.IsDeleted) @@ -328,7 +346,8 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService await _userManager.GiveUserSetRoleAsync(new List { id }, new List()); } - await UpsertUserLocationsAsync(id, mergedLocationIds); + await UpsertUserLocationsAsync(id, scope.LocationIds); + await UpsertTeamMemberScopeAsync(id, scope.AppliedRegionType, scope.AppliedLocationType); return await GetAsync(id); } @@ -356,6 +375,10 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService }) .Where(x => x.UserId == userIdString && !x.IsDeleted) .ExecuteCommandAsync(); + + await _dbContext.SqlSugarClient.Deleteable() + .Where(x => x.UserId == userIdString) + .ExecuteCommandAsync(); } /// @@ -923,6 +946,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService }).Where(x => x != null).Cast().ToList()); var scopeIdsMap = await BuildTeamMemberScopeIdsMapAsync(assignedMap, roleIdByUser); + var dimMap = await LoadTeamMemberScopeMapAsync(users.Select(u => u.Id)); var items = new List(); foreach (var u in users) @@ -932,6 +956,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService var userKey = TeamMemberListScopeHelper.UserKey(u.Id); assignedMap.TryGetValue(userKey, out var assigned); scopeIdsMap.TryGetValue(userKey, out var scopeIds); + dimMap.TryGetValue(userKey, out var dim); var partnerIds = scopeIds?.PartnerIds ?? new List(); var regionIds = scopeIds?.RegionIds ?? new List(); @@ -945,7 +970,8 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService listRoleId, partnerIds, regionIds, - assignedLocations); + assignedLocations, + dim?.AppliedLocationType); var locationIdList = assignedLocations .Select(x => x.Id) @@ -967,7 +993,13 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService (regionIds, locationIdList, assignedLocations) = await TeamMemberScopeDisplayHelper.CollapseScopeIdsToAllSentinelForEditAsync( - _dbContext.SqlSugarClient, partnerIds, regionIds, rawLocationIds, assignedLocations); + _dbContext.SqlSugarClient, + partnerIds, + regionIds, + rawLocationIds, + assignedLocations, + dim?.AppliedRegionType, + dim?.AppliedLocationType); items.Add(new TeamMemberGetListOutputDto { @@ -1106,32 +1138,63 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService public List RegionIds { get; init; } = new(); } - private Task> ResolveTeamMemberLocationIdsForSaveAsync(TeamMemberUpdateInputVo input) => - ResolveTeamMemberLocationIdsForSaveAsync(new TeamMemberCreateInputVo - { - PartnerId = input.PartnerId, - PartnerIds = input.PartnerIds, - RegionIds = input.RegionIds, - GroupIds = input.GroupIds, - LocationIds = input.LocationIds, - Locations = input.Locations - }, input.RoleId); + private sealed class TeamMemberScopeSaveResult + { + public string AppliedRegionType { get; init; } = AllScopeBindingHelper.ScopeSpecified; + public string AppliedLocationType { get; init; } = AllScopeBindingHelper.ScopeSpecified; + public List LocationIds { get; init; } = new(); + } - private async Task> ResolveTeamMemberLocationIdsForSaveAsync( + private sealed class TeamMemberScopeDimension + { + public string? AppliedRegionType { get; init; } + public string? AppliedLocationType { get; init; } + } + + private async Task ResolveTeamMemberScopeForSaveAsync( TeamMemberCreateInputVo input, Guid? roleId) { var partnerIds = NormalizePartnerIds(input); var regionIds = NormalizeRegionIds(input); var mergedLocationInputs = MergeLocationScopeInputs(input); - var locationHasAll = LocationScopeBindingHelper.ContainsAllScopeSentinel(mergedLocationInputs); + var locationHasAllSentinel = LocationScopeBindingHelper.ContainsAllScopeSentinel(mergedLocationInputs); var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedLocationInputs); - var regionHasAll = LocationScopeBindingHelper.ContainsAllScopeSentinel(regionIds); + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds); + var regionHasAllSentinel = LocationScopeBindingHelper.ContainsAllScopeSentinel(regionIds); var isCompanyAdmin = await TeamMemberRoleHelper.IsCompanyAdminRoleAsync( _dbContext.SqlSugarClient, roleId); + var regionHasAll = regionHasAllSentinel; + if (!regionHasAll && concreteRegions.Count > 0 && partnerIds.Count > 0) + { + var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync( + _dbContext.SqlSugarClient, partnerIds); + regionHasAll = allRegions.Count > 0 + && AllScopeBindingHelper.IsFullIdSelection(concreteRegions, allRegions); + } + + var locationCoversConcreteRegions = false; + if (!locationHasAllSentinel && concreteRegions.Count > 0 && !regionHasAll) + { + if (explicitLocationIds.Count == 0) + { + locationCoversConcreteRegions = true; + } + else + { + var regionUniverse = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( + _dbContext.SqlSugarClient, partnerIds, concreteRegions); + locationCoversConcreteRegions = regionUniverse.Count > 0 + && AllScopeBindingHelper.IsFullIdSelection( + explicitLocationIds, regionUniverse); + } + } + + var locationHasAll = locationHasAllSentinel || locationCoversConcreteRegions; + if (isCompanyAdmin && partnerIds.Count > 0 && - !regionHasAll && regionIds.Count == 0 && + !regionHasAll && concreteRegions.Count == 0 && !locationHasAll && explicitLocationIds.Count == 0) { var fromPartner = await LocationScopeBindingHelper.MergeToLocationIdsAsync( @@ -1142,11 +1205,29 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService } await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, fromPartner); - return fromPartner; + return new TeamMemberScopeSaveResult + { + AppliedRegionType = AllScopeBindingHelper.ScopeAll, + AppliedLocationType = AllScopeBindingHelper.ScopeAll, + LocationIds = fromPartner + }; + } + + // 传给落库解析:Location 区内全选时用 ALL 哨兵,避免被当成「部分门店」 + IReadOnlyList? locationInputForResolve = mergedLocationInputs; + if (locationCoversConcreteRegions && !locationHasAllSentinel) + { + locationInputForResolve = new List { AllScopeBindingHelper.ScopeAll }; + } + + IReadOnlyList? regionInputForResolve = regionIds; + if (regionHasAll && !regionHasAllSentinel) + { + regionInputForResolve = new List { AllScopeBindingHelper.ScopeAll }; } var merged = await LocationScopeBindingHelper.ResolveTeamMemberLocationIdsForSaveAsync( - _dbContext.SqlSugarClient, partnerIds, regionIds, mergedLocationInputs); + _dbContext.SqlSugarClient, partnerIds, regionInputForResolve, locationInputForResolve); if (merged.Count == 0) { @@ -1157,7 +1238,107 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService } await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged); - return merged; + + string appliedRegionType; + string appliedLocationType; + if (locationHasAll && (regionHasAll || concreteRegions.Count == 0)) + { + appliedRegionType = AllScopeBindingHelper.ScopeAll; + appliedLocationType = AllScopeBindingHelper.ScopeAll; + } + else if (locationHasAll) + { + appliedRegionType = AllScopeBindingHelper.ScopeSpecified; + appliedLocationType = AllScopeBindingHelper.ScopeAll; + } + else if (regionHasAll) + { + appliedRegionType = AllScopeBindingHelper.ScopeAll; + appliedLocationType = AllScopeBindingHelper.ScopeSpecified; + } + else + { + appliedRegionType = concreteRegions.Count > 0 + ? AllScopeBindingHelper.ScopeSpecified + : AllScopeBindingHelper.ScopeAll; + appliedLocationType = AllScopeBindingHelper.ScopeSpecified; + } + + return new TeamMemberScopeSaveResult + { + AppliedRegionType = appliedRegionType, + AppliedLocationType = appliedLocationType, + LocationIds = merged + }; + } + + private async Task LoadTeamMemberScopeAsync(Guid userId) + { + var row = await _dbContext.SqlSugarClient.Queryable() + .FirstAsync(x => x.UserId == userId.ToString()); + if (row is null) + { + return new TeamMemberScopeDimension(); + } + + return new TeamMemberScopeDimension + { + AppliedRegionType = row.AppliedRegionType, + AppliedLocationType = row.AppliedLocationType + }; + } + + private async Task UpsertTeamMemberScopeAsync( + Guid userId, + string appliedRegionType, + string appliedLocationType) + { + var userIdString = userId.ToString(); + var now = DateTime.Now; + var existing = await _dbContext.SqlSugarClient.Queryable() + .FirstAsync(x => x.UserId == userIdString); + if (existing is null) + { + await _dbContext.SqlSugarClient.Insertable(new FlTeamMemberScopeDbEntity + { + UserId = userIdString, + AppliedRegionType = appliedRegionType, + AppliedLocationType = appliedLocationType, + CreationTime = now, + LastModificationTime = now + }).ExecuteCommandAsync(); + return; + } + + existing.AppliedRegionType = appliedRegionType; + existing.AppliedLocationType = appliedLocationType; + existing.LastModificationTime = now; + await _dbContext.SqlSugarClient.Updateable(existing).ExecuteCommandAsync(); + } + + private async Task> LoadTeamMemberScopeMapAsync( + IEnumerable userIds) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var idStrings = userIds.Select(x => x.ToString()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (idStrings.Count == 0) + { + return result; + } + + var rows = await _dbContext.SqlSugarClient.Queryable() + .Where(x => idStrings.Contains(x.UserId)) + .ToListAsync(); + foreach (var row in rows) + { + result[TeamMemberListScopeHelper.NormalizeScopeKey(row.UserId)] = new TeamMemberScopeDimension + { + AppliedRegionType = row.AppliedRegionType, + AppliedLocationType = row.AppliedLocationType + }; + } + + return result; } /// 合并 (均可含 ALL)。 diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_entity_applied_region_type.sql b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_entity_applied_region_type.sql new file mode 100644 index 0000000..0ed4e88 --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_entity_applied_region_type.sql @@ -0,0 +1,46 @@ +-- 标签/产品分类等实体:适用 Region 维度 ALL/SPECIFIED(支持 Region=ALL + 指定门店) +-- 执行前请备份;可重复执行 + +SET @db := DATABASE(); + +-- fl_label_category +SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fl_label_category' AND COLUMN_NAME='AppliedRegionType'); +SET @ddl := IF(@c=0, + 'ALTER TABLE `fl_label_category` ADD COLUMN `AppliedRegionType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Region:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- fl_product_category +SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fl_product_category' AND COLUMN_NAME='AppliedRegionType'); +SET @ddl := IF(@c=0, + 'ALTER TABLE `fl_product_category` ADD COLUMN `AppliedRegionType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Region:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- fl_label_type +SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fl_label_type' AND COLUMN_NAME='AppliedRegionType'); +SET @ddl := IF(@c=0, + 'ALTER TABLE `fl_label_type` ADD COLUMN `AppliedRegionType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Region:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- fl_label_multiple_option +SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fl_label_multiple_option' AND COLUMN_NAME='AppliedRegionType'); +SET @ddl := IF(@c=0, + 'ALTER TABLE `fl_label_multiple_option` ADD COLUMN `AppliedRegionType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Region:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- fl_product +SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fl_product' AND COLUMN_NAME='AppliedRegionType'); +SET @ddl := IF(@c=0, + 'ALTER TABLE `fl_product` ADD COLUMN `AppliedRegionType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Region:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1'); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- 已有 SPECIFIED 门店范围的数据:Region 视为 SPECIFIED(由门店反推) +UPDATE `fl_label_category` SET `AppliedRegionType`='SPECIFIED' WHERE `AvailabilityType`='SPECIFIED'; +UPDATE `fl_product_category` SET `AppliedRegionType`='SPECIFIED' WHERE `AvailabilityType`='SPECIFIED'; +UPDATE `fl_label_type` SET `AppliedRegionType`='SPECIFIED' WHERE `AvailabilityType`='SPECIFIED'; +UPDATE `fl_label_multiple_option` SET `AppliedRegionType`='SPECIFIED' WHERE `AvailabilityType`='SPECIFIED'; +UPDATE `fl_product` SET `AppliedRegionType`='SPECIFIED' WHERE `AvailabilityType`='SPECIFIED'; diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_partner_id.sql b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_partner_id.sql new file mode 100644 index 0000000..8c062c8 --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_partner_id.sql @@ -0,0 +1,15 @@ +-- fl_label 适用 Company 单选(PartnerId) +-- 执行前请备份;可重复执行 + +SET @db := DATABASE(); + +SET @c := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'fl_label' AND COLUMN_NAME = 'PartnerId' +); +SET @ddl := IF( + @c = 0, + 'ALTER TABLE `fl_label` ADD COLUMN `PartnerId` varchar(36) NULL DEFAULT NULL COMMENT ''适用Company(fl_partner.Id,单选)'' AFTER `LocationId`', + 'SELECT 1' +); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_product_category_partner_scope.sql b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_product_category_partner_scope.sql new file mode 100644 index 0000000..543fe18 --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_product_category_partner_scope.sql @@ -0,0 +1,28 @@ +-- 产品分类适用 Company 多选(ALL/SPECIFIED) +-- 执行前请备份;可重复执行 + +SET @db := DATABASE(); + +SET @col_pc_partner := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'fl_product_category' AND COLUMN_NAME = 'AppliedPartnerType' +); +SET @ddl_pc_partner := IF( + @col_pc_partner = 0, + 'ALTER TABLE `fl_product_category` ADD COLUMN `AppliedPartnerType` varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''适用Company:ALL/SPECIFIED'' AFTER `AvailabilityType`', + 'SELECT 1' +); +PREPARE stmt_pc_partner FROM @ddl_pc_partner; +EXECUTE stmt_pc_partner; +DEALLOCATE PREPARE stmt_pc_partner; + +CREATE TABLE IF NOT EXISTS `fl_product_category_partner` ( + `Id` varchar(36) NOT NULL COMMENT '主键', + `CategoryId` varchar(36) NOT NULL COMMENT 'fl_product_category.Id', + `PartnerId` varchar(36) NOT NULL COMMENT 'fl_partner.Id', + `CreationTime` datetime NOT NULL COMMENT '创建时间', + `CreatorId` varchar(36) DEFAULT NULL COMMENT '创建者', + PRIMARY KEY (`Id`), + KEY `idx_fl_pcatp_category` (`CategoryId`), + KEY `idx_fl_pcatp_partner` (`PartnerId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品分类适用Company'; diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_team_member_scope.sql b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_team_member_scope.sql new file mode 100644 index 0000000..64574d6 --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_team_member_scope.sql @@ -0,0 +1,13 @@ +-- Team Member:Region / Location 维度 ALL 标记(支持 Region=ALL + 指定门店,及反向) +-- 执行前请备份;可重复执行 + +SET @db := DATABASE(); + +CREATE TABLE IF NOT EXISTS `fl_team_member_scope` ( + `UserId` varchar(36) NOT NULL COMMENT '成员 User.Id', + `AppliedRegionType` varchar(20) NOT NULL DEFAULT 'SPECIFIED' COMMENT '适用Region:ALL/SPECIFIED', + `AppliedLocationType` varchar(20) NOT NULL DEFAULT 'SPECIFIED' COMMENT '适用Location:ALL/SPECIFIED', + `CreationTime` datetime NULL, + `LastModificationTime` datetime NULL, + PRIMARY KEY (`UserId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Team Member 适用范围维度标记'; diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json b/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json index bb6dd53..42df6ad 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json @@ -20,7 +20,7 @@ }, //应用启动:SelfUrl 供 Program.cs UseUrls 绑定;用 0.0.0.0 避免写成固定局域网 IP 在本机无该网卡时启动失败(WinError 10049) "App": { - "SelfUrl": "http://192.168.31.88:19003", + "SelfUrl": "http://192.168.31.87:19003", "CorsOrigins": "http://localhost:19003;http://localhost:18000;http://localhost:5666;http://localhost:5174;http://localhost:5173;http://localhost:3000" }, //配置 diff --git a/项目相关文档/2026-07-24代码优化.md b/项目相关文档/2026-07-24代码优化.md index 6d4d8fc..972ab62 100644 --- a/项目相关文档/2026-07-24代码优化.md +++ b/项目相关文档/2026-07-24代码优化.md @@ -326,3 +326,147 @@ WHERE TemplateId = (SELECT Id FROM fl_label_template WHERE TemplateCode = 'tpl_x | `Helpers/AllScopeBindingHelper.cs` | 显式 `SPECIFIED` 时不因全选折叠为 `ALL` | | `Helpers/LabelTemplateScopeHelper.cs` | 保存传参对齐 Label 实体;详情/列表读取主表 scope 类型,避免门店反推覆盖 | | `Helpers/LabelTemplateScopeSchemaHelper.cs` | 批量读取 `AppliedPartnerType` / `AppliedRegionType` | + +--- + +## 七类实体编辑回显 ALL 哨兵(2026-07-24 续) + +### 背景 + +Team Member 已实现「全选回显 `["ALL"]`」规则。本次将同一约定推广至 Product、Product Category、Label、Label Category、Label Type、Label Template、Multiple Options Set 的 **GET 详情**(及列表若返回 scope Id 数组)。 + +- **落库不变**:仍可存展开 Guid 或 `Applied*Type=ALL` / `AvailabilityType=ALL`;显式 `SPECIFIED` 多 Id 落库逻辑不受影响。 +- **回显变更**:不再把 `Type=ALL` 展开为当前可见全量 Guid;改为返回 `["ALL"]` 哨兵,供编辑弹窗勾选 ALL。 +- **公共实现**:`ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync`(对齐 `TeamMemberScopeDisplayHelper.CollapseScopeIdsToAllSentinelForEditAsync` + `AllScopeBindingHelper.IsFullIdSelection`)。 + +### 折叠规则(与 Team Member 一致) + +| 条件 | 出参字段 | 值 | +|------|----------|-----| +| 覆盖全部 Company(系统级) | `partnerIds` / `companyIds` | `["ALL"]` | +| 覆盖该公司下全部 Region | `regionIds` / `groupIds` | `["ALL"]` | +| 覆盖该公司下全部门店 | `locationIds` / `appliedLocationIds` | `["ALL"]` | +| 主表 `Applied*Type=ALL` 或 `AvailabilityType=ALL` | 对应维度 Id 数组 | `["ALL"]`(不展开 Guid) | +| 未全选 | 各 Id 数组 | 具体 Guid 列表 | + +**说明**:Company 维度为 ALL 时 `partnerIds` 为 `["ALL"]`;Company 为 SPECIFIED 且 Region/Location 全选时,`partnerIds` 仍保留具体 Company Guid(与 Team Member 一致)。 + +### 字段对照表 + +| 实体 | 详情/列表接口 | Company | Region | Location | 类型字段 | +|------|---------------|---------|--------|----------|----------| +| Product | `GET /api/app/product/{id}`、列表 `locationIds` | `partnerIds` / `partnerId` | `groupIds` | `locationIds` | `availabilityType` | +| Product Category | `GET /api/app/product-category/{id}`、列表 | — | `regionIds` / `groupIds` | `locationIds` | `availabilityType` | +| Label | `GET /api/app/label/{id}`、列表 | `partnerIds` / `partnerId` | `regionIds` / `groupIds` | `locationIds` | `appliedRegionType` | +| Label Category | `GET /api/app/label-category/{id}`、列表 | `partnerIds` / `companyIds` | `regionIds` / `groupIds` | `locationIds` | `appliedPartnerType` + `availabilityType` | +| Label Type | `GET /api/app/label-type/{id}`、列表 | `partnerIds` / `companyIds` | `regionIds` / `groupIds` | `locationIds` | `appliedPartnerType` + `availabilityType` | +| Label Template | `GET /api/app/label-template/{code}`、列表 | `partnerIds` / `companyIds` | `regionIds` / `groupIds` | `locationIds` / `appliedLocationIds` | `appliedPartnerType` / `appliedRegionType` / `appliedLocationType` | +| Multiple Options Set | `GET /api/app/label-multiple-option/{id}`、列表 | `partnerIds` / `companyIds` | `regionIds` / `groupIds` | `locationIds` | `appliedPartnerType` + `availabilityType` | + +列表 **Region / Location 展示文案**(`All Region` / `All Location` / `All Companies`)保持原逻辑,仅 Id 数组改为哨兵。 + +### 改动文件 + +| 文件 | 变更 | +|------|------| +| `Helpers/ScopeAllEchoHelper.cs` | **新增** 公共 ALL 回显折叠 | +| `Helpers/LabelRegionScopeHelper.cs` | Label Region/Location 展示与 Id 折叠 | +| `Helpers/LabelTemplateScopeHelper.cs` | 模板列表/详情 scope Id 折叠 | +| `Helpers/LabelEntityPartnerScopeHelper.cs` | 标签实体 Company Id 折叠 | +| `Services/ProductAppService.cs` | 产品详情/列表 `locationIds`、`groupIds` | +| `Services/ProductCategoryAppService.cs` | 产品分类详情/列表 | +| `Services/LabelAppService.cs` | 标签详情/列表 | +| `Services/LabelCategoryAppService.cs` | 标签分类详情/列表 | +| `Services/LabelTypeAppService.cs` | 标签类型详情/列表;移除 `ExpandAllScopeIdsToDtoAsync` | +| `Services/LabelTemplateAppService.cs` | 模板详情;移除全量 Guid 展开 | +| `Services/LabelMultipleOptionAppService.cs` | 多选项详情/列表;移除 `ExpandAllScopeIdsToDtoAsync` | + +--- + +## 保存时 Id 数组含 ALL 哨兵(2026-07-24 续) + +### 背景 + +编辑弹窗回显 `regionIds` / `groupIds` / `locationIds: ["ALL"]` 后再次保存时,若后端把字面量 `"ALL"` 当作 Guid 做存在性校验,会报错(如 `门店Id格式不正确`、`存在无效的 Region`),并产生 `fl_group.Id = 'ALL'` 等无效 SQL。 + +### 约定(与 Team Member 对齐) + +| 入参 | 保存行为 | +|------|----------| +| `locationIds`(或 `appliedLocationIds`)含 `ALL` | **不归档 Guid 校验**;维度 Type 归档为 `ALL`,关联表不写快照 | +| `regionIds` / `groupIds` 含 `ALL` 且无具体门店 | 同上,Region/Availability 归档 `ALL` | +| `regionIds` 含 `ALL` + 具体 `locationIds` | Region 归档 `ALL`,仅落具体门店快照(Team Member 同语义) | +| `applied*Type: "SPECIFIED"` + Id 数组仅 `["ALL"]` | **识别为全选**,Type 归档为 `ALL`(Normalize 阶段转换,非 Guid 校验) | +| 显式 `SPECIFIED` + 多个具体 Guid | **不变**:全部落库,编辑仍回显具体 Id | + +**实现要点** + +- `AllScopeBindingHelper.Normalize*ScopeAsync` / `ShouldTreat*AsAllAsync`:先识别 `ALL` 哨兵,再决定是否展开或归档 ALL。 +- `LocationScopeBindingHelper.MergeToLocationIdsAsync` / `FilterConcreteScopeIds`:合并与校验前剥离 `ALL`,禁止传入 `Validate*ExistAsync`。 +- `LabelTemplateScopeHelper.ResolveScopeForSaveAsync`:经 Normalize 后 Region/Location 为 ALL 时跳过 Guid 存在性校验。 + +### 涉及接口(示例) + +- `POST/PUT /api/app/label-multiple-option` — **新增与编辑均支持** `regionIds` / `groupIds` / `locationIds` 传 `["ALL"]`;`availabilityType: SPECIFIED` + Id 数组 `["ALL"]` 仍归档为 `ALL` +- `POST/PUT /api/app/label-template` — **新增与编辑均支持** `appliedRegionType` / `appliedLocation: SPECIFIED` + `regionIds` / `groupIds` / `locationIds` / `appliedLocationIds: ["ALL"]`,Region/Location 维度归档 `ALL`;Partner 仍可 `SPECIFIED` + 多 `companyIds` +- `POST/PUT /api/app/label-type` — **新增与编辑均支持**(与 multiple-option 相同):`availabilityType: SPECIFIED` + `regionIds`/`groupIds`/`locationIds: ["ALL"]` → `AvailabilityType=ALL` +- `POST/PUT /api/app/label-category` — **新增与编辑均支持**(同上):`availabilityType: SPECIFIED` + `regionIds`/`groupIds`/`locationIds: ["ALL"]` → `AvailabilityType=ALL` +- `POST/PUT /api/app/label` — **新增与编辑均支持**:`appliedRegionType: SPECIFIED` + `regionIds`/`groupIds`/`locationIds: ["ALL"]` → `AppliedRegionType=ALL`(Create/Update 共用 `LabelRegionScopeHelper.ResolveScopeForSaveAsync`) +- `POST/PUT /api/app/product-category` — **新增与编辑均支持**:`availabilityType: SPECIFIED` + `regionIds`/`groupIds`/`locationIds: ["ALL"]` → `AvailabilityType=ALL` +- `POST/PUT /api/app/product` — **新增与编辑均支持**:`availabilityType: SPECIFIED` + `partnerId: "ALL"` 或 `groupIds`/`locationIds: ["ALL"]` → `AvailabilityType=ALL`,清空 `fl_location_product` 快照 + +### label-multiple-option 编辑传 ALL 示例 + +```json +PUT /api/app/label-multiple-option/{id} +{ + "optionName": "Size Options", + "appliedPartnerType": "SPECIFIED", + "companyIds": ["{partnerGuid1}", "{partnerGuid2}"], + "availabilityType": "SPECIFIED", + "regionIds": ["ALL"], + "groupIds": ["ALL"], + "locationIds": ["ALL"], + "state": true +} +``` + +**落库**:`AvailabilityType = ALL`,`fl_label_multiple_option_location` 无快照行;GET 回显 `regionIds` / `locationIds: ["ALL"]`。 + +### label-template 编辑传 ALL 示例 + +```json +PUT /api/app/label-template/{templateCode} +{ + "appliedPartnerType": "SPECIFIED", + "companyIds": ["{partnerGuid1}", "{partnerGuid2}"], + "appliedRegionType": "SPECIFIED", + "regionIds": ["ALL"], + "groupIds": ["ALL"], + "appliedLocation": "SPECIFIED", + "locationIds": ["ALL"], + "appliedLocationIds": ["ALL"] +} +``` + +**落库**:`AppliedPartnerType = SPECIFIED` 且写 `fl_label_template_partner`;`AppliedRegionType` / `AppliedLocationType = ALL`,Region/Location 关联表无快照;GET 回显 Region/Location Id 为 `["ALL"]`。 + +### 改动文件 + +| 文件 | 变更 | +|------|------| +| `Helpers/AllScopeBindingHelper.cs` | Normalize / ShouldTreat 识别 ALL 哨兵 | +| `Helpers/LocationScopeBindingHelper.cs` | Merge / Validate / ResolveEntityLocationIds 过滤 ALL | +| `Helpers/LabelRegionScopeHelper.cs` | Label 保存路径 ALL 预处理 | +| `Helpers/LabelTemplateScopeHelper.cs` | Create/Update 共用 ResolveScope 开头识别 ALL;ValidateRegion 过滤 ALL | +| `Services/LabelMultipleOptionAppService.cs` | `ResolveMultipleOptionScopeForSaveAsync` 开头 ALL 早返回;Create/Update XML | +| `Services/LabelTemplateAppService.cs` | 确认 Update 走 SaveTemplateScope + SetAppliedScopeTypes;Create/Update XML | + +### 验证 + +```bash +dotnet build module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj +``` + +保存后:主表 `Applied*Type` / `AvailabilityType` 为 `ALL`,关联表无快照行;`GET` 详情仍回显 `["ALL"]`。 + diff --git a/项目相关文档/2026-07-27代码优化.md b/项目相关文档/2026-07-27代码优化.md new file mode 100644 index 0000000..bb5270d --- /dev/null +++ b/项目相关文档/2026-07-27代码优化.md @@ -0,0 +1,321 @@ +# 2026-07-27 代码优化 + +## product-category 新增 Company 适用范围(companyIds) + +### 背景 + +产品分类(Product Category)与标签分类(Label Category)对齐,新增/编辑/详情/列表支持 **Company 多选** 传参与 **ALL 哨兵** 回显。 + +### 涉及接口 + +| 方法 | 路径 | +|------|------| +| GET | `/api/app/product-category/{id}` | +| GET | `/api/app/product-category`(列表 `items[]` 同步) | +| POST | `/api/app/product-category` | +| PUT | `/api/app/product-category/{id}` | + +示例详情:`GET /api/app/product-category/3a22b3d6-897d-d89a-e263-b30761fe48be` + +### 新增入参字段(POST / PUT) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `appliedPartnerType` | string | 适用 Company:`ALL` / `SPECIFIED` | +| `partnerIds` | string[] | Company Id(`fl_partner.Id`);与 `companyIds` 合并去重 | +| `companyIds` | string[] | 与 `partnerIds` 相同(推荐前端使用本字段) | + +原有 `availabilityType`、`regionIds` / `groupIds`、`locationIds` 规则不变,仍支持 `["ALL"]` 哨兵。 + +### 新增出参字段(GET 详情 / 列表) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `appliedPartnerType` | string | `ALL` / `SPECIFIED` | +| `company` | string | 展示文案:`All Companies` 或公司名称逗号拼接 | +| `partnerIds` | string[] | Company Id 数组 | +| `companyIds` | string[] | 与 `partnerIds` 相同 | + +### ALL 哨兵约定(与 Team Member / Label Category 一致) + +| 入参 | 保存行为 | +|------|----------| +| `companyIds: ["ALL"]` 或 `appliedPartnerType: "ALL"` | 主表 `AppliedPartnerType=ALL`,不写 `fl_product_category_partner` 快照 | +| `appliedPartnerType: "SPECIFIED"` + 具体 Guid | 写入 `fl_product_category_partner` 每 Company 一行 | +| `appliedPartnerType: "SPECIFIED"` + `companyIds: ["ALL"]` | **识别为全选**,归档 `AppliedPartnerType=ALL` | +| 勾选当前全部 Company(传全量 Guid) | 归档 `ALL`,编辑回显 `companyIds: ["ALL"]` | + +| 出参折叠 | 条件 | +|----------|------| +| `companyIds: ["ALL"]` | `AppliedPartnerType=ALL`,或绑定覆盖系统全部 Company | +| `regionIds` / `locationIds: ["ALL"]` | 见下方「范围内 ALL」规则 | + +**说明**:Company 为 `ALL` 时 Region/Location 可为 `ALL`;Company 为 `SPECIFIED` 且 Region/Location 全选时,`companyIds` 仍保留具体 Company Guid(与 Label Category 一致)。 + +### 范围内 ALL(2026-07-27 修复) + +编辑/新增若已指定具体 `companyIds`/`partnerIds` 或具体 `groupIds`/`regionIds`,再传 `locationIds: ["ALL"]`(或仅 Region=`ALL`): + +| 入参 | 落库 | 回显 | +|------|------|------| +| 具体 Company + 具体 Region + `locationIds:["ALL"]` | `AvailabilityType=SPECIFIED`,展开该 Region(且属该 Company)下门店快照 | Region 仍为具体 Id;Location 可折叠为 `["ALL"]` | +| 具体 Company + `locationIds:["ALL"]`(无具体 Region) | `SPECIFIED`,展开该公司全部门店 | Location/Region 按覆盖全集折叠 | +| 无具体 Company 且无具体 Region + Location/Region ALL | 仍为全局 `AvailabilityType=ALL`(不写门店快照) | Region/Location 均为 `["ALL"]` | + +**修复前问题**:具体 Company + Region + `locationIds:["ALL"]` 被误归档为全局 `AvailabilityType=ALL`,列表 Region/Location 都显示 All。 + +### 具体 locationIds 被 Region 并集冲掉(2026-07-27 再修) + +前端编辑时常同时传 `groupIds`/`regionIds`(当前 Region)+ `locationIds`(用户勾选的门店)。旧逻辑 `MergeToLocationIdsAsync(Region ∪ Location)` 会把「只选 1 个门店」扩成整 Region,回显再折成 `locationIds:["ALL"]`。 + +| 入参 | 修复后落库 | +|------|------------| +| 具体 `locationIds: ["{loc}"]`(可同传 Region) | **只绑该门店**,`AvailabilityType=SPECIFIED`,回显仍为该 Guid | +| 仅具体 Region、无具体 Location | 仍按 Region 展开门店 | +| 具体 Location 覆盖 Company 下全部门店 | 仍可归档为全局 `AvailabilityType=ALL` | + +### `locationIds:["ALL"]` 回显未折成 ALL(2026-07-27 再修) + +落库仍为 `AvailabilityType=SPECIFIED` + 区内门店快照(正确);但回显用 `ResolveAllLocationIdsAsync(Company, Region)` 时旧实现是 **Company∪Region 并集**,用「公司全部门店」去比,导致区内全选无法折叠为 `locationIds:["ALL"]`,列表 Location 也不显示 All。 + +**修复**:同时传 Company + Region 时改为**交集**。再编:具体 Company + Region + `locationIds:["ALL"]` → 回显 `locationIds:["ALL"]`,`regionIds` 仍为具体 Region。 + +### label-category / label-type / label-multiple-option 范围内 ALL(2026-07-27) + +与 product-category 对齐:具体 Company + Region + `locationIds:["ALL"]` 不再误归档全局 `AvailabilityType=ALL`,改为展开区内门店快照(`SPECIFIED`),回显 `locationIds` 可折回 `["ALL"]`。 + +共用:`LocationScopeBindingHelper.ExpandScopedAllLocationsForSaveAsync`。 + +### team-member 范围内 ALL(2026-07-27) + +| 入参 | 旧行为 | 新行为 | +|------|--------|--------| +| 具体 Company + 具体 Region + `locationIds/locations:["ALL"]` | 忽略 Region,展开**整公司**门店 | 展开**该 Region**(且属该公司)下门店 | +| 回显 | 非整公司时 `locationIds` 无法折成 ALL | 区内全选时 `locationIds:["ALL"]`,`regionIds` 仍为具体 Region | + +### Region=ALL + 单个 locationId(2026-07-27 再修) + +入参示例:`regionIds/groupIds:["ALL"]` + `locationIds:["{单店Guid}"]` + +| 旧问题 | 修复 | +|--------|------| +| 无 Region 维度字段,Region=ALL 无法落库;回显只能从门店反推具体 Region | 新增 `AppliedRegionType`(对齐 Label) | +| 单店落库后 Region 回显不成 ALL | `AppliedRegionType=ALL` + `AvailabilityType=SPECIFIED` + 门店快照 | + +**DDL**:执行 `美国版/.../scripts/fl_entity_applied_region_type.sql`(给 label-category / product-category / label-type / label-multiple-option 加列)。 + +落库后回显示例: +```json +{ + "appliedPartnerType": "SPECIFIED", + "availabilityType": "SPECIFIED", + "regionIds": ["ALL"], + "groupIds": ["ALL"], + "locationIds": ["3a22a490-6bcc-ee4f-2163-44f3e7b82906"] +} +``` + +### 请求示例(新增 / 编辑) + +```json +{ + "categoryName": "Prep", + "appliedPartnerType": "SPECIFIED", + "companyIds": ["{partnerGuid1}", "{partnerGuid2}"], + "availabilityType": "SPECIFIED", + "regionIds": ["ALL"], + "groupIds": ["ALL"], + "locationIds": ["ALL"], + "state": true, + "orderNum": 1 +} +``` + +全选 Company + 全选门店: + +```json +{ + "categoryName": "Prep", + "appliedPartnerType": "SPECIFIED", + "companyIds": ["ALL"], + "availabilityType": "SPECIFIED", + "regionIds": ["ALL"], + "locationIds": ["ALL"] +} +``` + +**落库**:`AppliedPartnerType=ALL`,`AvailabilityType=ALL`;不写 partner/location 关联快照。 + +### 响应示例(详情回显) + +```json +{ + "id": "3a22b3d6-897d-d89a-e263-b30761fe48be", + "categoryName": "Prep", + "appliedPartnerType": "ALL", + "company": "All Companies", + "companyIds": ["ALL"], + "partnerIds": ["ALL"], + "availabilityType": "ALL", + "regionIds": ["ALL"], + "groupIds": ["ALL"], + "locationIds": ["ALL"] +} +``` + +### 数据库迁移(部署前执行) + +脚本路径: + +`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_product_category_partner_scope.sql` + +内容概要: + +- `fl_product_category` 增加 `AppliedPartnerType`(默认 `ALL`) +- 新建 `fl_product_category_partner`(`CategoryId` + `PartnerId`) + +未执行迁移时:`AppliedPartnerType=ALL` 可正常保存;`SPECIFIED` + 具体 `companyIds` 保存会提示执行迁移。 + +### 改动文件 + +| 文件 | 变更 | +|------|------| +| `scripts/fl_product_category_partner_scope.sql` | **新增** 迁移脚本 | +| `DbModels/FlProductCategoryDbEntity.cs` | `AppliedPartnerType` | +| `DbModels/FlProductCategoryPartnerDbEntity.cs` | **新增** | +| `Helpers/LabelEntityPartnerScopeHelper.cs` | `ProductCategory` 种类 + 列表筛选 | +| `Dtos/ProductCategory/*` | 入参/出参 `companyIds` 等 | +| `Services/ProductCategoryAppService.cs` | Create/Update/Get/List 集成 Company 范围 | + +### 联调注意 + +1. **先执行 SQL 迁移**,再重启美国版 API。 +2. 编辑弹窗回显以 `companyIds` 为准(与 `partnerIds` 等价)。 +3. 列表 `company` 为展示字段;勾选状态以 `companyIds` 数组为准。 + +--- + +## 列表筛选:ALL 绑定命中任意具体 Id(2026-07-27 续) + +### 约定 + +| 实体绑定 | Query 传入 | 应命中 | +|----------|------------|--------| +| Company = `ALL`(`appliedPartnerType=ALL` / 无 partner 关联行) | 任意 `partnerId` | ✅ | +| Region = `ALL`(`availabilityType`/`appliedRegionType=ALL` / 无 region 关联行) | 任意 `groupId` | ✅ | +| Location = `ALL`(`availabilityType`/`appliedLocationType=ALL`) | 任意 `locationId` | ✅ | +| 维度 = `SPECIFIED` | 对应具体 Id | 仅关联命中 | + +### 涉及列表接口 + +- `GET /api/app/label-type` +- `GET /api/app/label-multiple-option` +- `GET /api/app/label-template` +- `GET /api/app/label-category` +- `GET /api/app/label`(原已支持 `AppliedRegionType=ALL`) +- `GET /api/app/product-category` +- `GET /api/app/product`(原已支持 `AvailabilityType=ALL`) +- `GET /api/app/product-location`(按 `locationId` 时并入 `AvailabilityType=ALL` 产品) + +### 实现要点 + +| 文件 | 变更 | +|------|------| +| `LabelEntityPartnerScopeHelper` | Partner 筛选:`AppliedPartnerType=ALL` **或** SPECIFIED 关联命中 | +| `LabelEntityListScopeHelper` | Location 筛选:`AvailabilityType=ALL` **或** SPECIFIED 门店命中(去掉「须 SPECIFIED Company」限制) | +| `LabelTemplateScopeHelper.ApplyTemplateScopeFilterAsync` | Company/Region 无关联行视为 ALL;Location 保留 `AppliedLocationType=ALL` | +| `ProductLocationAppService` | 按门店查时并入 `AvailabilityType=ALL` 产品 | + +### 联调示例 + +``` +GET /api/app/label-type?partnerId={任意公司Guid}&SkipCount=0&MaxResultCount=50 +``` + +绑定 `appliedPartnerType=ALL` 的类型应出现在结果中。 + +``` +GET /api/app/product-location?locationId={任意门店Guid}&MaxResultCount=2000 +``` + +`AvailabilityType=ALL` 的产品应与该门店 SPECIFIED 关联产品一并返回。 + +--- + +## label-multiple-option 仅 PartnerId 查不到第二家公司(2026-07-27 续) + +### 原因 + +列表在传 `PartnerId` 时会: + +1. 按 Company 关联表过滤(正确) +2. **再**把该公司下门店展开,与 `AvailabilityType` / 门店关联做 AND + +多公司绑定时,若第二家公司暂无门店、或门店未写入 location 关联表,第 2 步会把已命中的记录误杀。 + +### 修复 + +- **仅传 `PartnerId`**(未传 `GroupId` / `LocationId`):只按 Company 维度过滤,跳过门店 Availability AND +- 传了 `GroupId` / `LocationId`:仍按 Region/Location 过滤 +- 门店范围为 empty 时:保留 `AvailabilityType=ALL`,不再 `Where false` 清空 + +涉及:`label-multiple-option` / `label-type` / `label-category` / `product-category` / +`label-template` / `label` / `product` / `product-location` + +### 各接口要点 + +| 接口 | 修复 | +|------|------| +| label-type / label-category / product-category / label-multiple-option | 仅 `PartnerId` 跳过门店 AND | +| label-template | 仅 `PartnerId`:**只返回** `fl_label_template_partner` 含该公司的模板;不再把「无关联行」当成 ALL(避免查出其他公司/`AppliedPartnerType=ALL` 的无关数据) | +| label | 门店筛选补 `AppliedRegionType=ALL`;无门店时保留 ALL | +| product | 仅 `PartnerId` 且公司无门店时保留 `AvailabilityType=ALL` | +| product-location | 支持 `PartnerId`;按公司/门店查时并入 `AvailabilityType=ALL` | + +--- + +## product 新增/编辑 `companyIds`(单选)(2026-07-27 续) + +### 接口 + +| 方法 | 路径 | +|------|------| +| POST | `/api/app/product` | +| PUT | `/api/app/product/{id}` | +| GET | `/api/app/product/{id}`(编辑回显) | + +### 入参 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `companyIds` | string[] | **仅单选具体 Guid**;**不支持 ALL**;传多个 Guid 或含 ALL 报错 | +| `partnerId` | string | 兼容旧字段;与 `companyIds` 同时传须一致;**不支持 ALL** | + +### 出参(详情) + +| 字段 | 说明 | +|------|------| +| `companyIds` | 单选回显:最多 1 个具体 Guid;全部门店(AvailabilityType=ALL)时为空 | +| `partnerId` | 与 `companyIds[0]` 对齐;无 Company 时为 null | + +### 示例 + +```json +{ + "productName": "Milk", + "companyIds": ["3a22a4f2-1ec6-c7a1-b1a9-f82e71e9754c"], + "availabilityType": "SPECIFIED", + "groupIds": ["ALL"], + "locationIds": ["ALL"] +} +``` + +传 `companyIds: ["ALL"]` 或 `partnerId: "ALL"` 将返回错误:`产品适用 Company 不支持 ALL,请传单个具体 Company Id`。 + +### product `partnerId` 兼容数组 + 范围内 ALL(2026-07-27) + +| 问题 | 处理 | +|------|------| +| 前端传 `"partnerId":["{guid}"]` 导致 JSON 反序列化失败 | `PartnerId` 增加转换器,兼容字符串或数组(取首项) | +| 增加 `regionIds` | 与 `groupIds` 合并解析 | +| 具体 Company/Region + `locationIds:["ALL"]` | 同 product-category:展开为 SPECIFIED 快照,回显可折回 `["ALL"]` | diff --git a/项目相关文档/2026-08-05菜单.md b/项目相关文档/2026-08-05菜单.md new file mode 100644 index 0000000..dca33a5 --- /dev/null +++ b/项目相关文档/2026-08-05菜单.md @@ -0,0 +1,327 @@ +# 2026-08-05 泰额版:公司菜单权限 / 租户角色菜单接口 + +本文档说明 **泰额版**「平台给公司开通菜单」与「公司内角色编辑可选菜单」相关接口(2026-08-05 后端修复与补充)。 + +> **与侧边栏菜单不同**: +> - 侧边栏 / 动态路由:见 `2026-07-23泰额版当前登录账号菜单接口.md`(`auth-session/my-menus`) +> - 本文:平台分配给公司的 **SaaS 开通菜单**,以及租户内角色编辑时的 **可勾选菜单树** + +**Base URL 示例**:`http://127.0.0.1:19002`(以 `appsettings` / 部署环境为准)。 + +--- + +## 一、概念与数据源 + +| 概念 | 说明 | 存储位置 | +|------|------|----------| +| 公司开通菜单 | 平台管理员给某公司开通的模块范围 | 主库 `fl_th_tenant_menu_permission` | +| 可分配菜单树 | 角色/用户授权时勾选的树 | 主库 `menu`(公司上下文再按开通表裁剪) | +| 角色已绑菜单 | 某角色实际勾选的菜单 | 租户业务库 `rolemenu` | + +**节点 `key`**:菜单 Id(Guid 字符串,与租户业务库固定种子一致,如 `f0010001-0001-4000-8000-000000000001`)。 +历史 SaaS Key(`dashboard` / `labeling` / `management:...`)写入开通表时会归一化为菜单 Id。 + +**租户 Id 解析(公司端)**(优先级): + +1. `ICurrentTenant` / Header `__tenant` +2. JWT Claim:`TenantId` / `tenantid`(`th-web-auth` / `th-app-auth` 登录写入) + +排除:`00000000-...`、Default `11111111-1111-1111-1111-111111111111`(平台上下文,不按公司开通裁剪)。 + +--- + +## 二、接口对照 + +| 场景 | 方法 | 路径 | 谁用 | +|------|------|------|------| +| 可勾选菜单树 | GET | `/api/app/th-multi-tenancy/menu-permission-tree` | 平台配公司菜单;**公司端角色编辑** | +| 当前公司已开通菜单 | GET | `/api/app/th-multi-tenancy/my-company-menus` | **公司端**(JWT 解析租户,无需传 tenantId) | +| 指定公司已开通菜单 | GET | `/api/app/th-multi-tenancy/company-menus?tenantId=` | **平台端** | +| 覆盖设置公司开通菜单 | PUT | `/api/app/th-multi-tenancy/company-menus` | **平台端** | +| 角色详情(含已绑菜单) | GET | `/api/app/rbac-role/{id}` | 公司端角色编辑回显 | + +前端角色编辑「菜单权限」树:请求 `menu-permission-tree`;若返回空数组会 fallback 静态四菜单。后端在公司上下文过滤后为空时返回占位节点,避免误展示全量静态树。 + +--- + +## 三、可勾选菜单树 + +### 3.1 接口 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `GET /api/app/th-multi-tenancy/menu-permission-tree` | +| 鉴权 | `Authorization: Bearer {token}` | +| 说明 | 平台:全部可分配公司菜单;公司业务租户:仅该公司已开通菜单及祖先 | + +### 3.2 行为 + +| 登录身份 | 返回 | +|----------|------| +| 平台管理员(无业务 TenantId) | 全部可分配公司菜单树(排除仅平台端菜单) | +| 公司账号(JWT 含 TenantId) | 仅 `fl_th_tenant_menu_permission` 已开通项 + 祖先 | +| 公司已开通为空 | 单节点占位:`key=company-menu-empty`,`title=暂无可用菜单`(非 `[]`) | + +### 3.3 curl + +```bash +# 公司端登录 +curl -X POST "http://127.0.0.1:19002/api/app/th-web-auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"tenantId\":\"\",\"userName\":\"mai@123.com\",\"password\":\"123456\"}" + +# 拉可勾选菜单树(可不传 __tenant,JWT TenantId 亦可) +curl -G "http://127.0.0.1:19002/api/app/th-multi-tenancy/menu-permission-tree" \ + -H "Authorization: Bearer " \ + -H "__tenant: " +``` + +### 3.4 出参示例 + +```json +{ + "statusCode": 200, + "succeeded": true, + "data": [ + { + "key": "f0010001-0001-4000-8000-000000000001", + "title": "首页概览", + "children": null + }, + { + "key": "f0010010-0001-4000-8000-000000000010", + "title": "标签管理", + "children": [ + { + "key": "f0010011-0001-4000-8000-000000000011", + "title": "标签", + "children": null + } + ] + } + ] +} +``` + +节点字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `key` | string | 菜单 Id(Guid) | +| `title` | string | 菜单名称 | +| `children` | array / null | 子节点;无子级为 `null` | + +--- + +## 四、当前公司已开通菜单(公司端) + +### 4.1 接口 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `GET /api/app/th-multi-tenancy/my-company-menus` | +| 鉴权 | 公司端 Token(须能解析到业务租户) | +| 说明 | 读主库开通表;**无需** query `tenantId` | + +### 4.2 curl + +```bash +curl -G "http://127.0.0.1:19002/api/app/th-multi-tenancy/my-company-menus" \ + -H "Authorization: Bearer <公司token>" \ + -H "__tenant: " +``` + +### 4.3 出参 + +```json +{ + "statusCode": 200, + "succeeded": true, + "data": { + "tenantId": "3a22e29f-6657-818b-64b3-fdcc08ec9ed6", + "menuPermissionKeys": [ + "f0010001-0001-4000-8000-000000000001", + "f0010010-0001-4000-8000-000000000010", + "f0010011-0001-4000-8000-000000000011" + ] + } +} +``` + +| 字段 | 说明 | +|------|------| +| `tenantId` | 当前公司租户 Id | +| `menuPermissionKeys` | 已开通菜单 Id 列表(Guid 字符串) | + +未识别租户时: + +> 未识别租户上下文。请使用泰额登录接口选择具体公司登录,或请求头 `__tenant` 携带租户 Id。 + +--- + +## 五、指定公司开通菜单(平台端) + +### 5.1 查询 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `GET /api/app/th-multi-tenancy/company-menus?tenantId={guid}` | +| 鉴权 | 平台 Token | + +```bash +curl -G "http://127.0.0.1:19002/api/app/th-multi-tenancy/company-menus" \ + --data-urlencode "tenantId=" \ + -H "Authorization: Bearer <平台token>" +``` + +出参结构与 `my-company-menus` 相同。 + +### 5.2 覆盖设置 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `PUT /api/app/th-multi-tenancy/company-menus` | +| 鉴权 | 平台 Token | +| 说明 | 先删后插;`menuPermissionKeys` 传 `[]` / `null` 表示清空该公司开通菜单 | + +```bash +curl -X PUT "http://127.0.0.1:19002/api/app/th-multi-tenancy/company-menus" \ + -H "Authorization: Bearer <平台token>" \ + -H "Content-Type: application/json" \ + -d "{ + \"tenantId\": \"\", + \"menuPermissionKeys\": [ + \"f0010001-0001-4000-8000-000000000001\", + \"f0010010-0001-4000-8000-000000000010\", + \"f0010020-0001-4000-8000-000000000020\" + ] + }" +``` + +| 入参 | 必填 | 说明 | +|------|------|------| +| `tenantId` | 是 | 公司租户 Id | +| `menuPermissionKeys` | 否 | 菜单 Id 或历史 SaaS Key;覆盖式 | + +非法 key 返回 400:`存在非法菜单权限 Key:...` + +--- + +## 六、角色详情 / 绑定菜单(公司端) + +### 6.1 角色详情 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `GET /api/app/rbac-role/{id}` | +| 鉴权 | 公司端 Token + `__tenant`(业务库) | +| 说明 | 2026-08-05 起同时返回 `menuIds` 与 `menuPermissionKeys`(同值),兼容前端表单字段 | + +```bash +curl -G "http://127.0.0.1:19002/api/app/rbac-role/3a22e2a0-4a80-1723-0dd4-df8c31f72682" \ + -H "Authorization: Bearer <公司token>" \ + -H "__tenant: " +``` + +出参要点: + +| 字段 | 说明 | +|------|------| +| `id` / `roleName` / `roleCode` / … | 角色基本信息 | +| `menuIds` | 已绑定菜单 Id 列表 | +| `menuPermissionKeys` | 与 `menuIds` 同值(前端勾选回显用) | +| `accessPermissions` | 由 RoleMenu→Menu 汇总的权限码串 | +| `accessPermissionCodes` | 库内访问权限码列表 | + +列表 `GET /api/app/rbac-role` 每项也会填充 `menuPermissionKeys`(便于展示数量)。 + +### 6.2 创建 / 更新绑定 + +| 项 | 值 | +|----|------| +| 方法 | `POST /api/app/rbac-role`、`PUT /api/app/rbac-role/{id}` | +| 菜单入参优先级 | `menuIds` > `menuPermissionKeys` > `accessPermissions` | +| 公司租户约束 | 只能绑定该公司已开通菜单(及祖先);超出报错 | + +```json +{ + "roleName": "管理员", + "roleCode": "admin", + "remark": "管理员", + "orderNum": 999, + "state": true, + "menuPermissionKeys": [ + "f0010001-0001-4000-8000-000000000001", + "f0010010-0001-4000-8000-000000000010" + ] +} +``` + +超出开通范围时: + +> 角色菜单超出公司已开通范围:{menuId}, ... + +该公司尚未开通任何菜单时: + +> 该公司尚未开通任何菜单,请先在「菜单权限」中分配 + +--- + +## 七、常用菜单 Id 对照(种子) + +| SaaS Key(历史) | 菜单 Id | 说明 | +|------------------|---------|------| +| `dashboard` | `f0010001-0001-4000-8000-000000000001` | 首页概览 | +| `labeling` | `f0010010-0001-4000-8000-000000000010` | 标签管理 | +| `modules` | `f0010020-0001-4000-8000-000000000020` | 业务模块 | +| `management` | `f0010030-0001-4000-8000-000000000030` | 管理 | + +子菜单 Id 见种子脚本 / `ThSaasMenuPermissionCatalog`(如 `f0010011`…、`f0010031`…)。 + +--- + +## 八、联调检查清单 + +- [ ] 公司登录 Token 的 JWT 含 `TenantId`(或请求带 `__tenant`) +- [ ] `menu-permission-tree` 顶级节点与平台给该公司开通的模块一致(不应出现未开通模块) +- [ ] 平台只开前三时,树中不应出现第四个未开通模块;以主库 `fl_th_tenant_menu_permission` 为准 +- [ ] 开通为空时返回占位节点,而不是 `[]`(避免前端静态四菜单) +- [ ] `my-company-menus` 与 `company-menus?tenantId=` 对同一公司结果一致 +- [ ] `rbac-role/{id}` 含 `menuPermissionKeys`,与 `menuIds` 一致 +- [ ] 公司端 PUT 角色绑未开通菜单 → 400 + +查库核对: + +```sql +SELECT CAST(TenantId AS CHAR) AS TenantId, PermissionKey +FROM fl_th_tenant_menu_permission +WHERE TenantId = ''; +``` + +(主库:`antis-foodlabeling-host`;连接串注意 `utf8mb4`。) + +--- + +## 九、相关实现 + +| 模块 | 路径 | +|------|------| +| 多租户菜单 API | `FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs` | +| 公司菜单范围 | `FoodLabeling.Th.Application/MultiTenancy/TenantCompanyMenuScopeHelper.cs` | +| 租户 Id / JWT | `FoodLabeling.Application/Helpers/TenantBusinessContextHelper.cs` | +| 角色 API | `FoodLabeling.Application/Services/RbacRoleAppService.cs` | +| 角色菜单范围校验 | `FoodLabeling.Th.Application/Services/ThTenantScopedRbacRoleAppService.cs` | + +相关文档: + +- `2026-07-23泰额版当前登录账号菜单接口.md` — 侧边栏 my-menus +- `2026-07-23泰额版平台端操作公司级账号逻辑.md` — 平台配公司菜单 / admin +- `2026-07-23泰额版平台与公司登录拉菜单用法.md` — 登录与拉菜单用法 + +--- + +## 十、变更记录 + +| 日期 | 说明 | +|------|------| +| 2026-08-05 | 公司端 `menu-permission-tree` 按 JWT/`__tenant` + `fl_th_tenant_menu_permission` 裁剪;新增 `my-company-menus`;过滤空树返回占位节点;`rbac-role` 增加 `menuPermissionKeys` 出参/入参;公司端绑定角色菜单校验开通范围 | diff --git a/项目相关文档/2026-08-07告警接口文档.md b/项目相关文档/2026-08-07告警接口文档.md new file mode 100644 index 0000000..1df3283 --- /dev/null +++ b/项目相关文档/2026-08-07告警接口文档.md @@ -0,0 +1,323 @@ +# 告警计时器接口文档(泰额版) + +> 模块:标签告警计时器(Label Alert Timer) +> 服务:`LabelAlertTimerAppService` +> 范围:泰额版后端,数据在**租户业务库**(非 `antis-foodlabeling-host`) +> 认证:`Authorization: Bearer {token}`;业务请求建议带 `__tenant: {tenantId}` +> JSON:camelCase +> Base URL 示例:`http://127.0.0.1:19002`(以实际部署为准) +> 更新日期:2026-08-07 + +--- + +## 1. 业务说明 + +| 概念 | 说明 | +|------|------| +| 用途 | 跟踪**已经打印**的标签过期时间,供 App / 前端做**倒计时与警告列表** | +| 与打印关系 | **过期与「能不能打印」无关**;打印接口**不会**因计时器已过期而拒绝打印 | +| 过期时刻 | 与 Print Log「Expiration」列**同源**(`ReportsPrintLogExpiryHelper.TryResolveExpiryDateTime`) | +| 批次维度 | 同一 `BatchId` 无论打印多少张,**仅一条**计时器(取 `CopyIndex` 最小的 `fl_label_print_task`) | +| 无过期不上列表 | 模板无法解析过期时刻时,不写入计时器、不出现在列表 | +| 列表范围 | **门店级**:该 `locationId` 下未软删的全部计时器(不限当前用户自己打印) | +| 状态 | `running`(未过期)/ `expired`(已过期) | +| 软删 | 用户可删除计时器;删除后不再出现在列表,不影响历史打印任务 | + +### 写入时机 + +- `UsAppLabelingAppService.PrintAsync` / `ReprintAsync` 成功创建批次后自动写入(`LabelAlertTimerWriteHelper.TryCreateFromPrintBatchAsync`) +- 幂等重试(相同 `clientRequestId`)返回前也会补写一次(按 `BatchId` 唯一索引去重;含软删记录也不再插入) + +### 建表 + +| 项 | 说明 | +|----|------| +| 脚本 | `泰额版/.../module/food-labeling-us/scripts/fl_label_alert_timer.sql` | +| 表名 | `fl_label_alert_timer` | +| 新租户 | 开通时嵌入资源自动执行 | +| 已有租户 | 需用具备 CREATE 权限的账号在业务库手动执行(业务账号 `netteam` 通常无建表权限) | + +主要字段:`BatchId`(唯一)、`PrintTaskId`、`LabelId`、`LocationId`、`PrintedAt`、`ExpiresAt`、`DurationSeconds`、`Title`、`Subtitle`、`IsDeleted`。 + +--- + +## 2. 接口一览 + +| 功能 | 方法 | 路由 | 说明 | +|------|------|------|------| +| 分页列表 | POST | `/api/app/label-alert-timer/list` | `locationId` **必填** | +| App 警告列表 | POST | `/api/app/label-alert-timer/app-list` | **推荐 App 使用**;`locationId` 可空(走已选门店缓存) | +| 软删除 | DELETE | `/api/app/label-alert-timer/{id}` | 软删计时器 | +| 查询过期/倒计时 | POST | `/api/app/label-alert-timer/check-expired` | 单条状态查询;**仅展示,不拦打印** | + +> 全部需登录。列表 / 删除 / 查询均校验当前账号可访问对应门店。 + +### 获取 Token(App) + +```http +POST /api/app/th-app-auth/login +Content-Type: application/json + +{ "email": "mai@123.com", "password": "123456", "tenantId": "可选-租户Id" } +``` + +后续请求头: + +```http +Authorization: Bearer {token} +__tenant: {tenantId} +Content-Type: application/json +``` + +--- + +## 3. 分页列表 + +**POST** `/api/app/label-alert-timer/list` + +### 请求 + +```json +{ + "locationId": "3a21220f-db37-3e32-7390-d55f64cd62a8", + "skipCount": 1, + "maxResultCount": 20, + "dateDay": "2026-08-07" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| locationId | string | 是 | 当前门店 Id;空则报错「门店Id不能为空」 | +| skipCount | number | 是 | **页码**(从 1 开始,与项目分页约定一致) | +| maxResultCount | number | 是 | 每页条数 | +| dateDay | string | 否 | `yyyy-MM-dd`,按 `PrintedAt` 自然日筛选 | + +### 响应 + +```json +{ + "pageIndex": 1, + "pageSize": 20, + "totalCount": 1, + "totalPages": 1, + "items": [ + { + "id": "1987654321000123456", + "batchId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "printTaskId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + "labelId": "label-id-001", + "labelCode": "LB_001", + "title": "Chicken Prep (4 hours)", + "subtitle": "4 hours Completes at 2:30 PM", + "totalTime": 14400, + "remainingTime": 7200, + "status": "running", + "expiresAt": "2026-08-07T14:30:00", + "printedAt": "2026-08-07T10:30:00", + "locationId": "3a21220f-db37-3e32-7390-d55f64cd62a8", + "productName": "Grilled Chicken" + } + ] +} +``` + +### 列表项字段(`items[]`) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 计时器主键 | +| batchId | string | 打印批次 Id | +| printTaskId | string | 代表任务 Id(CopyIndex 最小) | +| labelId | string | 标签 Id | +| labelCode | string \| null | 标签编码 | +| title | string | 标题(含时长文案) | +| subtitle | string | 副标题(含完成时刻文案) | +| totalTime | number | 总时长(秒),同库字段 `DurationSeconds` | +| remainingTime | number | **剩余秒数**,`max(0, ExpiresAt - now)`;**App 倒计时用此字段** | +| status | string | `running` / `expired` | +| expiresAt | string | 过期时刻 | +| printedAt | string | 打印时刻 | +| locationId | string | 门店 Id | +| productName | string \| null | 产品名称 | + +排序:先 `ExpiresAt` 降序,再 `PrintedAt` 降序。 + +--- + +## 4. App 警告列表(推荐) + +**POST** `/api/app/label-alert-timer/app-list` + +当前登录账号可访问的门店下警告列表;出参结构与第 3 节 `list` **完全相同**(含 `remainingTime` 倒计时)。 + +### 请求 + +```json +{ + "locationId": "3a21220f-db37-3e32-7390-d55f64cd62a8", + "skipCount": 1, + "maxResultCount": 50 +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| locationId | string | 否 | 当前门店;**为空**时取 `POST /api/app/us-app-auth/select-admin-scope-location` 写入的已选门店缓存 | +| skipCount | number | 是 | 页码(从 1 开始) | +| maxResultCount | number | 是 | 每页条数 | +| dateDay | string | 否 | `yyyy-MM-dd`,按 `PrintedAt` 筛选 | + +### 门店解析规则 + +1. 入参 `locationId` 有值 → 用入参 +2. 入参为空 → 读管理员已选门店缓存 +3. 仍无门店 → 400:「请先选择门店或传入 locationId」 +4. 有门店但当前账号不可访问 → 权限校验失败(与 `list` 相同) + +### 倒计时对接 + +| 字段 | App 用法 | +|------|----------| +| remainingTime | 初始剩余秒数;进入页面后可本地每秒 `-1`,或定时重新拉列表校正 | +| totalTime | 进度条分母:`progress = (totalTime - remainingTime) / totalTime` | +| status | `expired` 时 remainingTime 为 0,可高亮/置顶 | +| expiresAt | 展示绝对过期时间;与 Print Log Expiration 对齐 | + +--- + +## 5. 软删除 + +**DELETE** `/api/app/label-alert-timer/{id}` + +| 项 | 说明 | +|----|------| +| 路径参数 id | 计时器主键 | +| 权限 | 校验当前用户可访问该计时器所属 `LocationId` | +| 行为 | `IsDeleted=1`,`DeletionTime=now` | +| 不存在/已删 | 报错「计时器不存在或已删除」 | + +无响应体(成功即可)。 + +--- + +## 6. 查询过期/倒计时状态 + +**POST** `/api/app/label-alert-timer/check-expired` + +仅查询**已打印**批次的过期状态与剩余秒数,供单条展示。 +**不得**用于拦截打印;打印流程不要依赖本接口结果做「禁止打印」。 + +### 请求 + +至少提供 `timerId`、`batchId`、`printTaskId` 之一(优先级:`timerId` > `batchId` > `printTaskId`): + +```json +{ + "batchId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +} +``` + +| 字段 | 说明 | +|------|------| +| timerId | 计时器 Id | +| batchId | 打印批次 Id | +| printTaskId | 打印任务 Id(同批次任意任务均可,会反查 BatchId) | + +未提供任一标识 → 「请至少提供 timerId、batchId 或 printTaskId 之一」。 + +### 响应(找到记录) + +```json +{ + "found": true, + "isExpired": false, + "expiresAt": "2026-08-07T14:30:00", + "remainingSeconds": 7200, + "status": "running", + "title": "Chicken Prep (4 hours)", + "subtitle": "4 hours Completes at 2:30 PM", + "timerId": "1987654321000123456", + "batchId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +} +``` + +### 响应(无记录) + +```json +{ + "found": false, + "isExpired": false, + "remainingSeconds": 0 +} +``` + +| 字段 | 说明 | +|------|------| +| found | 是否找到未删除的计时器 | +| isExpired | 是否已过期;无记录时为 `false` | +| remainingSeconds | 剩余秒数(已过期或无记录为 0);与列表的 `remainingTime` 含义相同 | +| status | `running` / `expired`;无记录时可能为空 | +| expiresAt / title / subtitle / timerId / batchId | 找到记录时有值 | + +--- + +## 7. 常见错误文案 + +| 场景 | 文案 | +|------|------| +| 入参为空 | 入参不能为空 | +| 未登录 | 用户未登录 | +| list 未传门店 | 门店Id不能为空 | +| app-list 无门店且无缓存 | 请先选择门店或传入 locationId | +| 删除 Id 为空 | 计时器Id不能为空 | +| 记录不存在/已删 | 计时器不存在或已删除 | +| check-expired 无标识 | 请至少提供 timerId、batchId 或 printTaskId 之一 | +| 无门店权限 | 由门店权限校验抛出(与打印日志门店校验一致) | + +--- + +## 8. curl 示例 + +```bash +# 1) 登录拿 Token(按实际环境替换) +curl -s -X POST "http://127.0.0.1:19002/api/app/th-app-auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"mai@123.com","password":"123456","tenantId":"TENANT_ID"}' + +# 2) App 警告列表(含倒计时) +curl -s -X POST "http://127.0.0.1:19002/api/app/label-alert-timer/app-list" \ + -H "Authorization: Bearer TOKEN" \ + -H "__tenant: TENANT_ID" \ + -H "Content-Type: application/json" \ + -d '{"locationId":"LOCATION_ID","skipCount":1,"maxResultCount":50}' + +# 3) 通用分页列表 +curl -s -X POST "http://127.0.0.1:19002/api/app/label-alert-timer/list" \ + -H "Authorization: Bearer TOKEN" \ + -H "__tenant: TENANT_ID" \ + -H "Content-Type: application/json" \ + -d '{"locationId":"LOCATION_ID","skipCount":1,"maxResultCount":20}' + +# 4) 单条过期/倒计时查询(仅展示) +curl -s -X POST "http://127.0.0.1:19002/api/app/label-alert-timer/check-expired" \ + -H "Authorization: Bearer TOKEN" \ + -H "__tenant: TENANT_ID" \ + -H "Content-Type: application/json" \ + -d '{"batchId":"YOUR_BATCH_ID"}' + +# 5) 软删除 +curl -s -X DELETE "http://127.0.0.1:19002/api/app/label-alert-timer/TIMER_ID" \ + -H "Authorization: Bearer TOKEN" \ + -H "__tenant: TENANT_ID" +``` + +--- + +## 9. 前端 / App 对接建议 + +1. **警告页**:优先调 `app-list`;用 `remainingTime`(秒)做倒计时;`status=expired` 可高亮。 +2. **不要做打印前过期拦截**:过期与可否打印无关;计时器在打印成功后才写入。 +3. **与 Print Log 一致**:展示过期时间时与 Print Log Expiration 列对齐,避免两套算法。 +4. **删除**:左滑/长按调 DELETE;仅隐藏计时器,不影响历史打印任务。 +5. **校正**:长时间停留页面时,可定时重拉 `app-list`,避免本地倒计时漂移。 diff --git a/项目相关文档/培训接口文档.md b/项目相关文档/培训接口文档.md new file mode 100644 index 0000000..bd2fd5e --- /dev/null +++ b/项目相关文档/培训接口文档.md @@ -0,0 +1,442 @@ +# 培训接口文档(泰额版) + +> 模块:培训 / 资料中心 +> 范围:泰额版后端(租户业务库,非 `antis-foodlabeling-host`) +> 认证:`Authorization: Bearer {token}`;业务请求建议带 `__tenant: {tenantId}` +> JSON:camelCase +> Base URL 示例:`http://127.0.0.1:19002`(以实际部署为准) +> 更新日期:2026-08-07 + +--- + +## 1. 业务说明 + +| 概念 | 说明 | +|------|------| +| 一级分类 | `parentId` 为空 | +| 二级分类 | `parentId` 指向一级分类 Id;**仅两级** | +| 培训文件 | **只能挂在二级分类下** | +| 文件权限 | 落在**文件**上:Company / Region / Location,各为 `ALL` 或 `SPECIFIED` | +| Company | `fl_partner`;字段 `partnerIds` / `companyIds` 等价 | +| Region | `fl_group`;字段 `regionIds` / `groupIds` 等价 | +| Location | `location`;字段 `locationIds` | + +### 权限约定(推荐做法) + +**主路径:上传 / 编辑文件时直接传公司、区域、门店范围**(可传 `ALL` 或具体多值 Id)。 +独立「编辑文件权限」接口仅作兼容保留,前端可不使用。 + +| 维度 | 类型字段 | Id 数组 | 说明 | +|------|----------|---------|------| +| Company | `appliedPartnerType` | `partnerIds` / `companyIds` | `ALL` 或 `SPECIFIED` + Guid 列表(可含哨兵 `"ALL"`) | +| Region | `appliedRegionType` | `regionIds` / `groupIds` | 同上 | +| Location | `availabilityType`(别名 `appliedLocationType`) | `locationIds` | 同上 | + +- 上传时**不传**任何 scope 字段 → 默认三维度均为 `ALL` +- 编辑时**不传**任何 scope 字段 → **不改**原权限(仅改文件名/排序) +- 编辑时传入任一 scope 字段 → 整套权限按入参覆盖保存 +- `SPECIFIED` 且仅选当前上下文「恰好全集」时,回显保持具体 Guid / 声明类型,不误折成 `["ALL"]`(与标签模板等一致) + +### 文件存储 + +| 环境 | 路径 | +|------|------| +| Linux 生产 | `/www/wwwroot/FoodLabelingManagementSAAS/training` | +| 本地兜底 | `{API ContentRoot}/wwwroot/FoodLabelingManagementSAAS/training` | +| 库中 `fileUrl` | `/training/{存储文件名}` | + +- 单文件最大 **20MB** +- 扩展名:`.jpg/.jpeg/.png/.webp/.gif/.bmp`、`.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx/.txt/.csv` +- `fileType`:`image` / `doc` / `other` + +### 建表 + +- 脚本:`module/food-labeling-us/scripts/fl_training.sql` +- 在每个**租户业务库**执行;新租户开通时会自动执行 + +--- + +## 2. 接口一览 + +### 2.1 管理端 `TrainingAppService` + +| 功能 | 方法 | 路由 | +|------|------|------| +| 分类树 | GET | `/api/app/training/category-tree` | +| 新增分类 | POST | `/api/app/training/category` | +| 编辑分类 | PUT | `/api/app/training/category/{id}` | +| 删除分类 | DELETE | `/api/app/training/category/{id}` | +| 上传文件(含权限) | POST | `/api/app/training/file/upload` | +| 编辑文件(含权限) | PUT | `/api/app/training/{id}/file` | +| 删除文件 | DELETE | `/api/app/training/{id}/file` | +| 文件排序 | PUT | `/api/app/training/sort-files` | +| 获取文件权限(兼容) | GET | `/api/app/training/file-scope/{id}` | +| 设置文件权限(兼容) | PUT | `/api/app/training/file-scope/{id}` | + +> 上传、`file-scope` 为显式路由;其余多为 ABP 约定路由。以 Swagger 为准。 +> 另有约定路径 `GET/PUT /api/app/training/{id}/file-scope`,与 `file-scope/{id}` 等价兼容。 + +### 2.2 APP `UsAppTrainingAppService` + +| 功能 | 方法 | 路由 | +|------|------|------| +| 门店可见分类树+文件 | GET | `/api/app/us-app-training/tree` | + +--- + +## 3. 管理端接口详情 + +### 3.1 获取分类树 + +`GET /api/app/training/category-tree` + +**Query** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| keyword | string | 否 | 匹配分类名或文件名 | +| locationId | string | 否 | 按门店过滤可见文件;不传则不过滤权限 | +| includeFiles | bool | 否 | 是否返回文件,默认 `true` | + +**响应要点**:一级 `children` 为二级;二级 `files` 为 `TrainingFileDto`(含完整 scope 回显字段)。 + +```json +[ + { + "id": "1001", + "categoryName": "分类1", + "parentId": null, + "orderNum": 100, + "children": [ + { + "id": "1002", + "categoryName": "子1", + "parentId": "1001", + "orderNum": 90, + "children": [], + "files": [ + { + "id": "23148558702612480", + "categoryId": "1002", + "fileName": "学情测评试题.pdf", + "fileUrl": "/training/20260807120000_xxx.pdf", + "fileType": "doc", + "fileSize": 102400, + "orderNum": 100, + "appliedPartnerType": "ALL", + "company": "All Companies", + "partnerIds": ["ALL"], + "companyIds": ["ALL"], + "appliedRegionType": "SPECIFIED", + "region": "武侯区", + "regionIds": ["3a22e2f6-0679-9a5b-19b4-fc6db2d92dcc"], + "groupIds": ["3a22e2f6-0679-9a5b-19b4-fc6db2d92dcc"], + "availabilityType": "SPECIFIED", + "location": "分店2", + "locationIds": ["3a22e2f6-e944-2038-ffd8-fc82d9d388ac"], + "creationTime": "2026-08-07T12:00:00", + "lastModificationTime": "2026-08-07T12:00:00" + } + ] + } + ], + "files": [] + } +] +``` + +--- + +### 3.2 新增分类 + +`POST /api/app/training/category` + +```json +{ + "categoryName": "Training", + "parentId": null, + "orderNum": 100 +} +``` + +| 字段 | 说明 | +|------|------| +| categoryName | 必填;同级不可重名 | +| parentId | 空 = 一级;传一级 Id = 二级 | +| orderNum | 排序 | + +**响应**:`TrainingCategoryGetOutputDto`(`id` / `categoryName` / `parentId` / `orderNum` / 时间字段) + +常见错误:名称为空、父级不存在、在二级下再建子级、同级重名。 + +--- + +### 3.3 编辑分类 + +`PUT /api/app/training/category/{id}` + +```json +{ + "categoryName": "Training(更新)", + "orderNum": 90 +} +``` + +不可改层级(`parentId` 不可改)。 + +--- + +### 3.4 删除分类 + +`DELETE /api/app/training/category/{id}` + +软删除。规则:一级下仍有二级不可删;二级下仍有文件不可删。 + +--- + +### 3.5 上传文件(主路径,含权限) + +`POST /api/app/training/file/upload` +`Content-Type: multipart/form-data` + +| 表单字段 | 类型 | 必填 | 说明 | +|----------|------|------|------| +| file | file | 是 | 文件本体 | +| categoryId | string | 是 | **二级**分类 Id | +| orderNum | int | 否 | 排序 | +| appliedPartnerType | string | 否 | `ALL` / `SPECIFIED` | +| partnerIds | string[] | 否 | 可重复传多个 form 字段;可含 `ALL` | +| companyIds | string[] | 否 | 同 partnerIds | +| appliedRegionType | string | 否 | `ALL` / `SPECIFIED` | +| regionIds | string[] | 否 | 可含 `ALL` | +| groupIds | string[] | 否 | 同 regionIds | +| availabilityType | string | 否 | Location:`ALL` / `SPECIFIED` | +| appliedLocationType | string | 否 | `availabilityType` 别名 | +| locationIds | string[] | 否 | 可含 `ALL` | + +**curl 示例(全 ALL)** + +```bash +curl -X POST "http://127.0.0.1:19002/api/app/training/file/upload" \ + -H "Authorization: Bearer " \ + -H "__tenant: " \ + -F "file=@./手册.pdf" \ + -F "categoryId=<二级分类Id>" \ + -F "orderNum=100" \ + -F "appliedPartnerType=ALL" \ + -F "partnerIds=ALL" \ + -F "appliedRegionType=ALL" \ + -F "regionIds=ALL" \ + -F "availabilityType=ALL" \ + -F "locationIds=ALL" +``` + +**curl 示例(指定公司 + 门店)** + +```bash +curl -X POST "http://127.0.0.1:19002/api/app/training/file/upload" \ + -H "Authorization: Bearer " \ + -H "__tenant: " \ + -F "file=@./手册.pdf" \ + -F "categoryId=<二级分类Id>" \ + -F "appliedPartnerType=SPECIFIED" \ + -F "partnerIds=" \ + -F "appliedRegionType=SPECIFIED" \ + -F "regionIds=" \ + -F "availabilityType=SPECIFIED" \ + -F "locationIds=" \ + -F "locationIds=" +``` + +**响应**:完整 `TrainingFileDto`(含 scope 回显,结构见 3.1)。 + +--- + +### 3.6 编辑文件(主路径,含权限) + +`PUT /api/app/training/{id}/file` + +```json +{ + "fileName": "操作手册.pdf", + "orderNum": 100, + "appliedPartnerType": "SPECIFIED", + "partnerIds": ["3a22e2f5-a785-ba39-9847-de45bdd47e50"], + "companyIds": ["3a22e2f5-a785-ba39-9847-de45bdd47e50"], + "appliedRegionType": "ALL", + "regionIds": ["ALL"], + "groupIds": ["ALL"], + "availabilityType": "SPECIFIED", + "locationIds": ["3a22e2f6-e944-2038-ffd8-fc82d9d388ac"] +} +``` + +| 字段 | 说明 | +|------|------| +| fileName | 必填;展示名(不换物理文件) | +| orderNum | 排序 | +| scope 各字段 | 见第 1 节;**只要传了任一 scope 字段即整套覆盖**;全不传则保持原权限 | + +**响应**:完整 `TrainingFileDto`。 + +```bash +curl -X PUT "http://127.0.0.1:19002/api/app/training//file" \ + -H "Authorization: Bearer " \ + -H "__tenant: " \ + -H "Content-Type: application/json" \ + -d "{\"fileName\":\"学情测评试题.pdf\",\"orderNum\":100,\"appliedPartnerType\":\"ALL\",\"partnerIds\":[\"ALL\"],\"appliedRegionType\":\"ALL\",\"availabilityType\":\"ALL\",\"locationIds\":[\"ALL\"]}" +``` + +--- + +### 3.7 删除文件 + +`DELETE /api/app/training/{id}/file` + +软删除,并清理 scope 关联行。 + +--- + +### 3.8 文件排序 + +`PUT /api/app/training/sort-files` + +```json +{ + "items": [ + { "id": "23148558702612480", "orderNum": 100 }, + { "id": "23148558702612481", "orderNum": 90 } + ] +} +``` + +--- + +### 3.9 获取 / 设置文件权限(兼容,非推荐主路径) + +| 方法 | 路由 | +|------|------| +| GET | `/api/app/training/file-scope/{id}` | +| PUT | `/api/app/training/file-scope/{id}` | + +**推荐**:权限在 **上传 / 编辑文件** 中一并提交,不必单独调本接口。 +本接口与 create/update 共用同一套 scope 保存逻辑;PUT body 与 scope 字段相同(无 `fileName`)。 + +**GET 响应**(`TrainingFileScopeOutputDto`) + +```json +{ + "appliedPartnerType": "SPECIFIED", + "company": "成都分店", + "partnerIds": ["3a22e2f5-a785-ba39-9847-de45bdd47e50"], + "companyIds": ["3a22e2f5-a785-ba39-9847-de45bdd47e50"], + "appliedRegionType": "SPECIFIED", + "region": "武侯区", + "regionIds": ["3a22e2f6-0679-9a5b-19b4-fc6db2d92dcc"], + "groupIds": ["3a22e2f6-0679-9a5b-19b4-fc6db2d92dcc"], + "availabilityType": "SPECIFIED", + "location": "分店2", + "locationIds": ["3a22e2f6-e944-2038-ffd8-fc82d9d388ac"] +} +``` + +**PUT body 示例** + +```json +{ + "appliedPartnerType": "ALL", + "partnerIds": ["ALL"], + "appliedRegionType": "SPECIFIED", + "regionIds": ["3a22e2f6-0679-9a5b-19b4-fc6db2d92dcc"], + "availabilityType": "SPECIFIED", + "locationIds": ["3a22e2f6-e944-2038-ffd8-fc82d9d388ac"] +} +``` + +--- + +## 4. APP 接口 + +### 4.1 门店可见分类树 + +`GET /api/app/us-app-training/tree` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| locationId | string | 是 | 当前门店 Id;校验用户可访问该门店 | +| keyword | string | 否 | 匹配分类名或文件名 | + +按门店过滤文件:Company / Region / Location 三维度 `ALL` 或 `SPECIFIED` 命中该门店才可见。 +响应结构同管理端分类树(`includeFiles=true`)。 + +```bash +curl -G "http://127.0.0.1:19002/api/app/us-app-training/tree" \ + --data-urlencode "locationId=" \ + -H "Authorization: Bearer " \ + -H "__tenant: " +``` + +--- + +## 5. TrainingFileDto 字段说明 + +| 字段 | 说明 | +|------|------| +| id | 文件 Id | +| categoryId | 所属二级分类 | +| fileName / fileUrl / fileType / fileSize | 文件信息 | +| orderNum | 排序 | +| appliedPartnerType | Company:`ALL` / `SPECIFIED` | +| company | Company 展示文案 | +| partnerIds / companyIds | Company Id 列表(可含 `ALL`) | +| appliedRegionType | Region:`ALL` / `SPECIFIED` | +| region | Region 展示文案 | +| regionIds / groupIds | Region Id 列表 | +| availabilityType | Location:`ALL` / `SPECIFIED` | +| location | Location 展示文案 | +| locationIds | 门店 Id 列表 | +| creationTime / lastModificationTime | 时间 | + +--- + +## 6. 联调检查清单 + +- [ ] 分类仅两级;文件只能挂二级 +- [ ] 上传可不传 scope → 默认三维度 ALL +- [ ] 上传/编辑传 `ALL` 或具体多值 Guid → 库表 type 字段 + 关联表正确 +- [ ] 分类树 `files[]` 含完整 scope 回显 +- [ ] `PUT .../file` 不传 scope 时不改权限;传了则覆盖 +- [ ] `GET/PUT /api/app/training/file-scope/{id}` 不再 404(兼容路径) +- [ ] APP `us-app-training/tree` 按 locationId 过滤可见文件 +- [ ] 文件 ≤20MB、扩展名合法 + +查库(租户业务库): + +```sql +SELECT Id, FileName, AppliedPartnerType, AppliedRegionType, AvailabilityType +FROM fl_training_file WHERE IsDeleted = 0; + +SELECT * FROM fl_training_file_partner WHERE TrainingFileId = ''; +SELECT * FROM fl_training_file_region WHERE TrainingFileId = ''; +SELECT * FROM fl_training_file_location WHERE TrainingFileId = ''; +``` + +--- + +## 7. 相关代码 + +| 说明 | 路径 | +|------|------| +| 管理端服务 | `FoodLabeling.Application/Services/TrainingAppService.cs` | +| APP 服务 | `FoodLabeling.Application/Services/UsAppTrainingAppService.cs` | +| Scope 辅助 | `FoodLabeling.Application/Helpers/TrainingFileScopeHelper.cs` | +| 建表脚本 | `module/food-labeling-us/scripts/fl_training.sql` | + +--- + +## 8. 变更记录 + +| 日期 | 说明 | +|------|------| +| 2026-08-07 | 重写文档:权限并入上传/编辑;file-scope 改为兼容路径;补全 TrainingFileDto scope 回显与 curl 示例 | +| (历史) | 初版:分类树、文件 CRUD、独立 file-scope、APP tree | diff --git a/项目相关文档/泰鄂版新增租户接口文档.md b/项目相关文档/泰鄂版新增租户接口文档.md new file mode 100644 index 0000000..34909b2 --- /dev/null +++ b/项目相关文档/泰鄂版新增租户接口文档.md @@ -0,0 +1,279 @@ +# 泰鄂版新增租户接口文档 + +> 模块:租户开通 `ThTenantProvisioningAppService` + 平台租户管理 +> 库:主库 `antis-foodlabeling-host`(`YiTenant` + `fl_th_tenant_admin_credential`)+ 租户独立业务库 +> 认证:需平台管理员登录 Token(`Authorization: Bearer {token}`) +> 说明:平台登录时请求头 `__tenant` 可为全 0 / 空,下列平台接口不要求业务租户上下文 + +--- + +## 1. 业务说明 + +新增租户(开通公司)时: + +1. 在主库登记 `YiTenant` +2. **必须传登录邮箱 `email`**(作为该公司 Web 登录账号) +3. 校验邮箱格式,并做**全局唯一**校验 +4. 写入 `fl_th_tenant_admin_credential`(`LoginAccount` = 邮箱) +5. 可选后台初始化业务库;完成后把 Seed 默认 `admin` 同步为该邮箱 + +### 邮箱唯一性规则 + +邮箱不可与以下任一重复(忽略大小写): + +| 范围 | 校验对象 | +|------|----------| +| 平台主库 | `User.Email` / `User.UserName`(平台管理员账号) | +| 各公司凭据 | `fl_th_tenant_admin_credential.LoginAccount` | + +--- + +## 2. 接口一览 + +| 功能 | 方法 | 路由 | +|------|------|------| +| 新增租户 / 开通公司 | POST | `/api/app/th-tenant-provisioning/provision`(推荐) | +| 新增租户(简写同义) | POST | `/api/app/th-tenant-provisioning` | +| 同步初始化业务库 | POST | `/api/app/th-tenant-provisioning/initialize-tenant-database/{tenantId}` | +| 同步初始化(query) | POST | `/api/app/th-tenant-provisioning/initialize-tenant-database?tenantId=` | +| 删除租户(框架,前端常用) | DELETE | `/api/app/tenant?ids={tenantId}` | +| 删除公司(推荐,可 DROP 业务库) | DELETE | `/api/app/th-multi-tenancy/company/{tenantId}?dropDatabase=true` | + +> 路由为 ABP 约定;若与 Swagger 不一致,以 Swagger 为准。 + +--- + +## 3. 新增租户 + +### `POST /api/app/th-tenant-provisioning/provision` + +> 亦可:`POST /api/app/th-tenant-provisioning`(与上同义) + +**Headers** + +```http +Authorization: Bearer {platformToken} +Content-Type: application/json +__tenant: 00000000-0000-0000-0000-000000000000 +``` + +**Body** + +```json +{ + "name": "中国麦当劳公司", + "email": "mcdonalds.admin@example.com", + "adminPassword": "ChangeMe123!", + "initializeDatabase": true, + "databaseKey": null, + "tenantConnectionString": null, + "dbType": 0 +} +``` + +兼容旧前端:可用 `adminUserName` 代替 `email`(须为邮箱形态)。 + +### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | 是 | 租户/公司名称 | +| email | string | 是* | 管理员登录邮箱;全局唯一 | +| adminUserName | string | 是* | 兼容字段,等同 email(二选一,优先 email) | +| adminPassword | string | 否 | 初始明文密码;不传则用 `RbacOptions.AdminPassword` | +| initializeDatabase | bool | 否 | 默认 `true`:建库后后台 CodeFirst + Seed,并同步邮箱账号 | +| databaseKey | string | 否 | 库名片段;空则从 name 清洗 | +| tenantConnectionString | string | 否 | 自定义连接串;空则按 `FoodLabeling:TenantDatabase` 模板生成 | +| dbType | int | 否 | SqlSugar DbType,默认 `0`=MySql | + +### 成功响应示例 + +```json +{ + "statusCode": 200, + "succeeded": true, + "data": { + "tenantId": "3a2xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "中国麦当劳公司", + "email": "mcdonalds.admin@example.com", + "databaseName": "antis-foodlabeling-xxxxxxx", + "tenantConnectionString": "server=...;database=antis-foodlabeling-xxxxxxx;...", + "databaseInitialized": false, + "databaseInitializing": true + } +} +``` + +| 出参 | 说明 | +|------|------| +| tenantId | 新租户 Id(登录选公司时用) | +| email | 登记的登录邮箱 | +| databaseInitializing | `true` 表示业务库正在后台初始化 | +| databaseInitialized | 本接口立即返回时一般为 `false`;完成后可用同步初始化接口确认 | + +### 常见错误(400) + +| 错误信息 | 原因 | +|----------|------| +| 租户名称不能为空 | 未传 `name` | +| 登录邮箱不能为空 | 未传 `email` | +| 登录邮箱格式不正确 | 非邮箱形态 | +| 登录邮箱「xxx」已被平台账号占用 | 与主库平台用户冲突 | +| 登录邮箱「xxx」已被其他公司管理员占用 | 与其他公司 `LoginAccount` 冲突 | +| 管理员初始密码不能为空 | 未传密码且系统未配置默认 AdminPassword | + +--- + +## 4. 同步初始化业务库(可选补跑) + +### `POST /api/app/th-tenant-provisioning/initialize-tenant-database/{tenantId}` + +用于: + +- 开通时 `initializeDatabase=false` +- 后台初始化失败后的手动补跑 + +完成后会再次尝试把租户库默认 `admin` 同步为开通时登记的邮箱。 + +> 耗时较长,服务端已配置较长请求超时。 + +--- + +## 5. 删除租户 + +平台管理员删除公司时,`__tenant` 可为全 0,**不要求**业务租户上下文。 + +### 5.1 框架删除(前端 SAAS 公司页当前调用) + +`DELETE /api/app/tenant?ids={tenantId}` + +```http +DELETE /api/app/tenant?ids=3a229a02-77eb-1dcc-afb6-09e2a2b6386c +Authorization: Bearer {platformToken} +__tenant: 00000000-0000-0000-0000-000000000000 +``` + +| 说明 | | +|------|--| +| 行为 | 主库软删 `YiTenant`(`TenantService.DeleteAsync`,已走 Host scope) | +| 注意 | **不会** DROP 业务库;建议重要环境用 5.2 | +| 修复 | 平台路径白名单已包含 `/api/app/tenant`,不再因 `__tenant` 全 0 报「未识别租户上下文」 | + +### 5.2 推荐:删除公司(可 DROP 业务库) + +`DELETE /api/app/th-multi-tenancy/company/{tenantId}?dropDatabase=true` + +```http +DELETE /api/app/th-multi-tenancy/company/3a229a02-77eb-1dcc-afb6-09e2a2b6386c?dropDatabase=true +Authorization: Bearer {platformToken} +__tenant: 00000000-0000-0000-0000-000000000000 +``` + +| 参数 | 说明 | +|------|------| +| tenantId | 路径参数,租户 Id | +| dropDatabase | 默认 `true`:尝试 DROP 业务库;`false` 仅删主库记录 | + +**成功响应示例** + +```json +{ + "tenantId": "3a229a02-77eb-1dcc-afb6-09e2a2b6386c", + "name": "中国麦当劳公司", + "databaseName": "antis-foodlabeling-t46d56528", + "databaseDropped": true, + "tenantDeleted": true, + "message": null +} +``` + +**保护规则** + +- 禁止删除 Default 租户 +- 禁止 DROP `antis-foodlabeling-host`、`antis-foodlabeling-us` +- 会清理 `fl_th_tenant_admin_credential`、`fl_th_tenant_menu_permission` + +--- + +## 6. 登录方式(开通后) + +业务库初始化并完成邮箱同步后,使用泰额登录: + +```http +POST /api/app/th-web-auth/login +Content-Type: application/json +``` + +```json +{ + "tenantId": "{tenantId}", + "userName": "mcdonalds.admin@example.com", + "password": "ChangeMe123!" +} +``` + +说明: + +- 选**具体公司** `tenantId`,用开通时的 **email** 登录 +- 平台管理员邮箱请选 **Default**,不要用公司租户登录 + +--- + +## 7. curl 示例 + +```bash +# 平台 Token 登录后开通公司 +curl -X POST "{{baseUrl}}/api/app/th-tenant-provisioning/provision" \ + -H "Authorization: Bearer {{platformToken}}" \ + -H "Content-Type: application/json" \ + -H "__tenant: 00000000-0000-0000-0000-000000000000" \ + -d "{ + \"name\": \"中国麦当劳公司\", + \"email\": \"mcdonalds.admin@example.com\", + \"adminPassword\": \"ChangeMe123!\", + \"initializeDatabase\": true + }" + +# 简写路由同义 +curl -X POST "{{baseUrl}}/api/app/th-tenant-provisioning" \ + -H "Authorization: Bearer {{platformToken}}" \ + -H "Content-Type: application/json" \ + -H "__tenant: 00000000-0000-0000-0000-000000000000" \ + -d "{ + \"name\": \"测试公司\", + \"email\": \"test.admin@example.com\", + \"adminPassword\": \"ChangeMe123!\" + }" + +# 缺邮箱 → 400 +curl -X POST "{{baseUrl}}/api/app/th-tenant-provisioning/provision" \ + -H "Authorization: Bearer {{platformToken}}" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"测试公司\"}" + +# 删除租户(框架接口,前端常用) +curl -X DELETE "{{baseUrl}}/api/app/tenant?ids=3a229a02-77eb-1dcc-afb6-09e2a2b6386c" \ + -H "Authorization: Bearer {{platformToken}}" \ + -H "__tenant: 00000000-0000-0000-0000-000000000000" + +# 删除公司(推荐,可 DROP 库) +curl -X DELETE "{{baseUrl}}/api/app/th-multi-tenancy/company/3a229a02-77eb-1dcc-afb6-09e2a2b6386c?dropDatabase=true" \ + -H "Authorization: Bearer {{platformToken}}" \ + -H "__tenant: 00000000-0000-0000-0000-000000000000" +``` + +--- + +## 8. 相关代码 + +| 项 | 路径 | +|----|------| +| 开通服务 | `FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs` | +| 入参 | `Dtos/MultiTenancy/ThProvisionTenantInputVo.cs` | +| 出参 | `Dtos/MultiTenancy/ThProvisionTenantOutputDto.cs` | +| 邮箱同步 | `MultiTenancy/TenantAdminAccountBootstrapper.cs` | +| 后台初始化 | `MultiTenancy/ThTenantDatabaseBackgroundInitializer.cs` | +| 删除公司 | `Services/ThMultiTenancyAppService.DeleteCompanyAsync` | +| 框架删租户 | `Yi.Framework.TenantManagement.Application/TenantService.DeleteAsync` | +| 平台路径白名单 | `Filters/FoodLabelingBusinessTenantActionFilter`(含 `/api/app/tenant`) | +| 管理员凭据表 | `fl_th_tenant_admin_credential`(主库) | diff --git a/项目相关文档/美国版服务器采购配置清单.md b/项目相关文档/美国版服务器采购配置清单.md new file mode 100644 index 0000000..f7dfebe --- /dev/null +++ b/项目相关文档/美国版服务器采购配置清单.md @@ -0,0 +1,122 @@ +# 美国版服务器采购配置清单 + +> 适用范围:美国版 Web 管理端 + UniApp 员工端 +> 技术栈:.NET 8 API + MySQL + React 静态站 +> 原则:独立环境,不与泰额版共用资源 + +--- + +## 1. 环境定位 + +| 项 | 说明 | +|---|---| +| 用户区域 | 美国 | +| 后端 | `Yi.Abp.Web`(.NET 8 + SqlSugar) | +| 前端 | React 18 + Vite 静态托管 | +| 移动端 | UniApp(调用同一套 API) | +| 文件存储现状 | 本地磁盘(图片、批量导入模板) | +| Redis | 配置存在,当前默认关闭 | + +--- + +## 2. 必买清单 + +| 序号 | 项 | 建议规格 | 备注 | +|---|---|---|---| +| 1 | 云厂商 + 地域 | AWS / Azure / GCP,**美东或美西** | 就近访问、合规 | +| 2 | 应用服务器 | **4 核 8G**,系统盘 **40~80G SSD** ×1(建议预留扩到 2 台) | 运行 API | +| 3 | MySQL(RDS) | **2~4 核 / 4~8G 内存**,存储 **50~100G SSD** | 开启自动备份 7~30 天 | +| 4 | 负载均衡 / 公网 | SLB 或云 LB;带宽 **5~20Mbps** 或按量 | API 对外入口 | +| 5 | 域名 + SSL | 1 个主域名;建议 `api.` / `admin.` 子域 | HTTPS 必需 | +| 6 | 反向代理 | Nginx(可与 API 同机) | 反代 API + 托管 React 静态文件 | +| 7 | 安全组 / 防火墙 | 仅开放 80/443;SSH 限 IP | 数据库不对公网开放 | +| 8 | 备份 + 监控 | RDS 自动备份;CPU / 内存 / 磁盘 / 5xx 告警 | 云厂商自带即可 | + +--- + +## 3. 建议购买(正式上线) + +| 序号 | 项 | 建议规格 | 备注 | +|---|---|---|---| +| 9 | 对象存储 S3 / OSS | **50~100G** + 按量流出 | 图片、导入文件;后期多实例必备 | +| 10 | CDN(可选) | 绑定静态站 / 图片桶 | App 拉图更快 | + +--- + +## 4. 可暂缓 + +| 项 | 说明 | +|---|---| +| Redis | 当前关闭;验证码 / 多实例后再上,**1G** 即可 | +| 第二台 API | 有滚动发布 / 高可用需求再买 | +| 独立日志 / 消息队列 | 当前架构暂不需要 | +| 独立邮件服务 | 已有 Office365 SMTP 可继续使用 | + +--- + +## 5. OSS 结论 + +| 阶段 | 是否购买 | 说明 | +|---|---|---| +| 内测 / 单机试跑 | 可不买 | 本地盘可用 | +| 正式上线 | **建议购买** | 换机不丢文件,方便横向扩容 | +| 容量建议 | 50~100G | 按租户 / 业务前缀隔离目录 | + +未切 OSS 前,本地目录参考: + +- 图片:`/www/wwwroot/FoodLabelingManagementUs/picture` +- 批量导入模板:`/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles` + +未上 OSS 时,建议系统盘 **≥ 80G**,避免图片打满磁盘。 + +> 说明:购买 OSS 后需改造上传逻辑(如 `PictureAppService`);可先开桶,代码分迭代切换。 + +--- + +## 6. 软件与部署要求 + +| 项 | 内容 | +|---|---| +| 运行时 | .NET 8 Runtime、Nginx | +| API | `Yi.Abp.Web` | +| 前端 | React Vite 构建产物由 Nginx 托管 | +| 数据库访问 | 仅 VPC 内网,不对公网 | +| 发布方式 | 建议预留第二台机器做滚动发布 | + +--- + +## 7. 一页勾选版 + +``` +美国版生产环境 +□ 美区 VPC / 账号 +□ ECS/VM 4C8G ×1(系统盘 80G) +□ MySQL RDS 2~4C / 4~8G / 50~100G + 自动备份 +□ SLB + 公网带宽 +□ 域名 + SSL 证书 +□ Nginx(API 反代 + Web 静态) +□ 安全组:80/443;RDS 仅内网 +□ 监控告警 +□ S3/OSS 50~100G(正式建议勾选) +□(可选)Redis 1G +□(可选)CDN +□(可选)第二台 4C8G(高可用) +``` + +--- + +## 8. 预算量级(仅供参考) + +| 场景 | 粗算月费 | +|---|---| +| 起步(1 台 API + RDS + 小容量 S3) | 约 **$80~200 / 月** | +| 扩容方向 | 优先加 RDS 规格,其次加第二台 API;OSS 按量扩容 | + +--- + +## 9. 采购顺序建议 + +1. 确定云厂商与美区地域 +2. 购买 RDS + 应用机 + 域名证书 +3. 同步开通 S3/OSS 桶(即使第一期仍写本地) +4. 跑通发布后再评估 Redis、第二台 API、CDN