Commit 0075d0e2744df78e66793194f230600b72ee382c

Authored by 杨鑫
2 parents 5fe058a8 0a93339e

1

Showing 239 changed files with 14786 additions and 1727 deletions

Too many changes.

To preserve performance only 100 of 239 files are displayed.

泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs
... ... @@ -21,10 +21,13 @@ namespace Yi.Framework.SqlSugarCore
21 21 {
22 22 #region Properties
23 23  
  24 + private ISqlSugarClient? _sqlSugarClient;
  25 + private readonly object _clientLock = new();
  26 +
24 27 /// <summary>
25   - /// SqlSugar客户端实例
  28 + /// SqlSugar 客户端(延迟按当前租户解析连接串,避免构造时 CurrentTenant 尚未就绪落到 host 库)
26 29 /// </summary>
27   - public ISqlSugarClient SqlSugarClient { get; private set; }
  30 + public ISqlSugarClient SqlSugarClient => GetOrCreateClient();
28 31  
29 32 /// <summary>
30 33 /// 延迟服务提供者
... ... @@ -75,22 +78,62 @@ namespace Yi.Framework.SqlSugarCore
75 78 public SqlSugarDbContextFactory(IAbpLazyServiceProvider lazyServiceProvider)
76 79 {
77 80 LazyServiceProvider = lazyServiceProvider;
  81 + }
78 82  
79   - // 异步获取租户配置
80   - var tenantConfiguration = AsyncHelper.RunSync(async () => await TenantConfigurationWrapper.GetAsync());
81   -
82   - // 构建数据库连接配置
83   - var connectionConfig = BuildConnectionConfig(options =>
  83 + private ISqlSugarClient GetOrCreateClient()
  84 + {
  85 + // 必须每次按当前租户解析连接串:MultiTenancyMiddleware 查 YiTenant 时会先 Change(null)
  86 + // 创建主库客户端;若缓存不切换,后续业务会误连 host。
  87 + var tenantConfiguration =
  88 + AsyncHelper.RunSync(async () => await TenantConfigurationWrapper.GetAsync());
  89 + var connectionString = tenantConfiguration.GetCurrentConnectionString();
  90 + var dbType = GetCurrentDbType(tenantConfiguration.GetCurrentConnectionName());
  91 +
  92 + if (_sqlSugarClient is not null &&
  93 + string.Equals(
  94 + _sqlSugarClient.CurrentConnectionConfig?.ConnectionString,
  95 + connectionString,
  96 + StringComparison.Ordinal))
84 97 {
85   - options.ConnectionString = tenantConfiguration.GetCurrentConnectionString();
86   - options.DbType = GetCurrentDbType(tenantConfiguration.GetCurrentConnectionName());
87   - });
  98 + return _sqlSugarClient;
  99 + }
88 100  
89   - // 创建SqlSugar客户端实例
90   - SqlSugarClient = new SqlSugarClient(connectionConfig);
  101 + lock (_clientLock)
  102 + {
  103 + if (_sqlSugarClient is not null &&
  104 + string.Equals(
  105 + _sqlSugarClient.CurrentConnectionConfig?.ConnectionString,
  106 + connectionString,
  107 + StringComparison.Ordinal))
  108 + {
  109 + return _sqlSugarClient;
  110 + }
91 111  
92   - // 配置数据库AOP
93   - ConfigureDbAop(SqlSugarClient);
  112 + if (_sqlSugarClient is not null)
  113 + {
  114 + try
  115 + {
  116 + _sqlSugarClient.Dispose();
  117 + }
  118 + catch
  119 + {
  120 + // ignore dispose race
  121 + }
  122 +
  123 + _sqlSugarClient = null;
  124 + }
  125 +
  126 + var connectionConfig = BuildConnectionConfig(options =>
  127 + {
  128 + options.ConnectionString = connectionString;
  129 + options.DbType = dbType;
  130 + });
  131 +
  132 + var client = new SqlSugarClient(connectionConfig);
  133 + ConfigureDbAop(client);
  134 + _sqlSugarClient = client;
  135 + return _sqlSugarClient;
  136 + }
94 137 }
95 138  
96 139 /// <summary>
... ...
泰额版/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;
5 5 /// </summary>
6 6 public class CurrentUserBriefDto
7 7 {
  8 + /// <summary>
  9 + /// 当前登录用户 Id(非租户 Id)。
  10 + /// </summary>
8 11 public Guid Id { get; set; }
9 12  
10 13 public string UserName { get; set; } = string.Empty;
... ...
泰额版/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;
5 5 /// </summary>
6 6 public class CurrentUserMenuPermissionsOutputDto
7 7 {
  8 + /// <summary>
  9 + /// 当前登录用户 Id(JSON: <c>userId</c>)。与 <see cref="User"/>.<see cref="CurrentUserBriefDto.Id"/> 一致,非租户 Id。
  10 + /// </summary>
  11 + public Guid UserId { get; set; }
  12 +
8 13 public CurrentUserBriefDto User { get; set; } = new();
9 14  
10 15 public List<string> RoleCodes { get; set; } = new();
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Common/ITrainingFileScopeInput.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Common;
  2 +
  3 +/// <summary>
  4 +/// 培训文件创建/编辑入参中的 Company / Region / Location 适用范围字段。
  5 +/// </summary>
  6 +public interface ITrainingFileScopeInput : ILabelEntityPartnerScopeInput
  7 +{
  8 + /// <summary>适用 Region:ALL / SPECIFIED</summary>
  9 + string? AppliedRegionType { get; }
  10 +
  11 + /// <summary>适用 Region(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并;可含 <c>ALL</c></summary>
  12 + List<string>? RegionIds { get; }
  13 +
  14 + /// <summary>与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c></summary>
  15 + List<string>? GroupIds { get; }
  16 +
  17 + /// <summary>适用 Location:ALL / SPECIFIED(与 <see cref="AppliedLocationType"/> 二选一)</summary>
  18 + string? AvailabilityType { get; }
  19 +
  20 + /// <summary>适用 Location:ALL / SPECIFIED(<see cref="AvailabilityType"/> 别名)</summary>
  21 + string? AppliedLocationType { get; }
  22 +
  23 + /// <summary>适用门店(<c>location.Id</c>);可含 <c>ALL</c></summary>
  24 + List<string>? LocationIds { get; }
  25 +}
... ...
泰额版/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
13 13  
14 14 public List<string>? PartnerIds { get; set; }
15 15  
  16 + /// <summary>适用 Company(单选)</summary>
  17 + public List<string>? CompanyIds { get; set; }
  18 +
16 19 public string? AppliedRegionType { get; set; }
17 20  
18 21 public List<string>? RegionIds { get; set; }
... ...
泰额版/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;
2 2  
3 3 public class LabelCreateInputVo
4 4 {
  5 + /// <summary>标签编码(可选;未传或空字符串时由后端自动生成唯一编码)</summary>
5 6 public string? LabelCode { get; set; }
6 7  
7 8 public string LabelName { get; set; } = string.Empty;
... ... @@ -9,32 +10,38 @@ public class LabelCreateInputVo
9 10 public string TemplateCode { get; set; } = string.Empty;
10 11  
11 12 /// <summary>
12   - /// 适用 Company(<c>fl_partner.Id</c>);与 <see cref="PartnerIds"/> 合并,用于解析所属门店
  13 + /// 适用 Company(<c>fl_partner.Id</c>);与 <see cref="PartnerIds"/> / <see cref="CompanyIds"/> 合并,单选
13 14 /// </summary>
14 15 public string? PartnerId { get; set; }
15 16  
16 17 /// <summary>
17   - /// 适用 Company 多选(<c>fl_partner.Id</c>
  18 + /// 适用 Company 多选字段(仅支持 1 个;与 <see cref="CompanyIds"/> 相同
18 19 /// </summary>
19 20 public List<string>? PartnerIds { get; set; }
20 21  
21 22 /// <summary>
22   - /// 适用 Region 范围:<c>ALL</c>(全选)/ <c>SPECIFIED</c>(按 <see cref="RegionIds"/>)。传了有效 regionIds 时按 SPECIFIED 处理。
  23 + /// 适用 Company(单选,<c>fl_partner.Id</c>);与 <see cref="PartnerId"/> / <see cref="PartnerIds"/> 合并
  24 + /// </summary>
  25 + public List<string>? CompanyIds { get; set; }
  26 +
  27 + /// <summary>
  28 + /// 适用 Region 范围:<c>ALL</c> / <c>SPECIFIED</c>。
  29 + /// 亦可在 <see cref="RegionIds"/> / <see cref="LocationIds"/> 传哨兵 <c>["ALL"]</c>(即使本字段为 SPECIFIED 亦归档 ALL;POST/PUT 均支持)。
23 30 /// </summary>
24 31 public string? AppliedRegionType { get; set; }
25 32  
26 33 /// <summary>
27   - /// 适用 Region 多选(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并,落库 <c>fl_label_region</c>
  34 + /// 适用 Region 多选(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并;可含 <c>ALL</c>
28 35 /// </summary>
29 36 public List<string>? RegionIds { get; set; }
30 37  
31 38 /// <summary>
32   - /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同)
  39 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同);可含 <c>ALL</c>
33 40 /// </summary>
34 41 public List<string>? GroupIds { get; set; }
35 42  
36 43 /// <summary>
37   - /// 适用门店 Id 数组(<c>location.Id</c>,落库 <c>fl_label_location</c>);主字段
  44 + /// 适用门店 Id 数组(<c>location.Id</c>);可含 <c>ALL</c>;落库 <c>fl_label_location</c>
38 45 /// </summary>
39 46 public List<string>? LocationIds { get; set; }
40 47  
... ...
泰额版/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
17 17  
18 18 public string LocationName { get; set; } = string.Empty;
19 19  
20   - /// <summary>适用 Company Id(由所属门店反推)</summary>
  20 + /// <summary>适用 Company Id(单选;优先落库 <c>fl_label.PartnerId</c>)</summary>
21 21 public string? PartnerId { get; set; }
22 22  
23 23 public List<string> PartnerIds { get; set; } = new();
24 24  
  25 + /// <summary>与 <see cref="PartnerIds"/> 相同(兼容字段,单选)</summary>
  26 + public List<string> CompanyIds { get; set; } = new();
  27 +
25 28 /// <summary>适用 Region 范围:ALL / SPECIFIED</summary>
26 29 public string AppliedRegionType { get; set; } = "SPECIFIED";
27 30  
... ...
泰额版/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
10 10  
11 11 public List<string>? PartnerIds { get; set; }
12 12  
  13 + /// <summary>适用 Company(单选);与 <see cref="PartnerId"/> / <see cref="PartnerIds"/> 合并</summary>
  14 + public List<string>? CompanyIds { get; set; }
  15 +
13 16 /// <summary>
14   - /// 适用 Region 范围:<c>ALL</c> / <c>SPECIFIED</c>(见 <see cref="RegionIds"/>)
  17 + /// 适用 Region 范围:<c>ALL</c> / <c>SPECIFIED</c>。
  18 + /// 亦可在 <see cref="RegionIds"/> / <see cref="LocationIds"/> 传哨兵 <c>["ALL"]</c>(与新增一致,PUT 支持)。
15 19 /// </summary>
16 20 public string? AppliedRegionType { get; set; }
17 21  
  22 + /// <summary>适用 Region 多选;可含 <c>ALL</c></summary>
18 23 public List<string>? RegionIds { get; set; }
19 24  
  25 + /// <summary>与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c></summary>
20 26 public List<string>? GroupIds { get; set; }
21 27  
22 28 /// <summary>
23   - /// 适用门店 Id 数组(<c>location.Id</c>,落库 <c>fl_label_location</c>);主字段,全量覆盖
  29 + /// 适用门店 Id 数组;可含 <c>ALL</c>;落库 <c>fl_label_location</c>,全量覆盖
24 30 /// </summary>
25 31 public List<string>? LocationIds { get; set; }
26 32  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerCheckExpiredInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer;
  2 +
  3 +/// <summary>
  4 +/// 检查告警计时器是否过期入参(至少提供一个标识)
  5 +/// </summary>
  6 +public class LabelAlertTimerCheckExpiredInputVo
  7 +{
  8 + public string? TimerId { get; set; }
  9 +
  10 + public string? BatchId { get; set; }
  11 +
  12 + public string? PrintTaskId { get; set; }
  13 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerCheckExpiredOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer;
  2 +
  3 +/// <summary>
  4 +/// 检查告警计时器是否过期出参
  5 +/// </summary>
  6 +public class LabelAlertTimerCheckExpiredOutputDto
  7 +{
  8 + /// <summary>是否找到计时器记录</summary>
  9 + public bool Found { get; set; }
  10 +
  11 + /// <summary>是否已过期;无记录时为 false(仅展示用,不用于拦截打印)</summary>
  12 + public bool IsExpired { get; set; }
  13 +
  14 + public DateTime? ExpiresAt { get; set; }
  15 +
  16 + /// <summary>剩余秒数(已过期为 0)</summary>
  17 + public int RemainingSeconds { get; set; }
  18 +
  19 + /// <summary>状态:<c>expired</c> 或 <c>running</c></summary>
  20 + public string? Status { get; set; }
  21 +
  22 + public string? Title { get; set; }
  23 +
  24 + public string? Subtitle { get; set; }
  25 +
  26 + public string? TimerId { get; set; }
  27 +
  28 + public string? BatchId { get; set; }
  29 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerGetListInputVo.cs 0 → 100644
  1 +using Volo.Abp.Application.Dtos;
  2 +
  3 +namespace FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer;
  4 +
  5 +/// <summary>
  6 +/// 告警计时器分页列表入参
  7 +/// </summary>
  8 +public class LabelAlertTimerGetListInputVo : PagedAndSortedResultRequestDto
  9 +{
  10 + /// <summary>
  11 + /// 当前门店 Id(location.Id,Guid 字符串)。
  12 + /// <c>list</c> 必填;<c>app-list</c> 可空(空则取已选门店缓存)。
  13 + /// </summary>
  14 + public string? LocationId { get; set; }
  15 +
  16 + /// <summary>
  17 + /// 打印日期(<c>yyyy-MM-dd</c>);按 PrintedAt 筛选该日记录。
  18 + /// </summary>
  19 + public string? DateDay { get; set; }
  20 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelAlertTimer/LabelAlertTimerListItemDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer;
  2 +
  3 +/// <summary>
  4 +/// 告警计时器列表项
  5 +/// </summary>
  6 +public class LabelAlertTimerListItemDto
  7 +{
  8 + public string Id { get; set; } = string.Empty;
  9 +
  10 + public string BatchId { get; set; } = string.Empty;
  11 +
  12 + public string PrintTaskId { get; set; } = string.Empty;
  13 +
  14 + public string LabelId { get; set; } = string.Empty;
  15 +
  16 + public string? LabelCode { get; set; }
  17 +
  18 + public string Title { get; set; } = string.Empty;
  19 +
  20 + public string Subtitle { get; set; } = string.Empty;
  21 +
  22 + /// <summary>总时长(秒),同 DurationSeconds</summary>
  23 + public int TotalTime { get; set; }
  24 +
  25 + /// <summary>剩余时长(秒)</summary>
  26 + public int RemainingTime { get; set; }
  27 +
  28 + /// <summary>状态:<c>expired</c> 或 <c>running</c></summary>
  29 + public string Status { get; set; } = string.Empty;
  30 +
  31 + public DateTime ExpiresAt { get; set; }
  32 +
  33 + public DateTime PrintedAt { get; set; }
  34 +
  35 + public string LocationId { get; set; } = string.Empty;
  36 +
  37 + public string? ProductName { get; set; }
  38 +}
... ...
泰额版/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;
4 4  
5 5 public class LabelCategoryCreateInputVo : ILabelEntityPartnerScopeInput
6 6 {
7   - public string CategoryCode { get; set; } = string.Empty;
  7 + /// <summary>分类编码(可选;未传或空字符串时由后端自动生成唯一编码)</summary>
  8 + public string? CategoryCode { get; set; }
8 9  
9 10 public string CategoryName { get; set; } = string.Empty;
10 11  
... ... @@ -37,25 +38,28 @@ public class LabelCategoryCreateInputVo : ILabelEntityPartnerScopeInput
37 38 public List<string>? CompanyIds { get; set; }
38 39  
39 40 /// <summary>
40   - /// 门店可用范围:ALL / SPECIFIED
  41 + /// 门店可用范围:ALL / SPECIFIED。
  42 + /// <c>regionIds</c>/<c>groupIds</c>/<c>locationIds</c> 可传哨兵 <c>["ALL"]</c>(大小写不敏感);
  43 + /// 即使本字段为 SPECIFIED,Id 数组为 ALL 时仍归档为 ALL(POST/PUT 均支持)。
41 44 /// </summary>
42 45 public string AvailabilityType { get; set; } = "ALL";
43 46  
44 47 /// <summary>
45   - /// 适用 Region(多选),<c>fl_group.Id</c>;与 <see cref="GroupIds"/> 合并去重
  48 + /// 适用 Region(多选),<c>fl_group.Id</c>;与 <see cref="GroupIds"/> 合并去重;可含 <c>ALL</c>
46 49 /// </summary>
47 50 public List<string>? RegionIds { get; set; }
48 51  
49 52 /// <summary>
50   - /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同
  53 + /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c>
51 54 /// </summary>
52 55 public List<string>? GroupIds { get; set; }
53 56  
54 57 /// <summary>
55   - /// 适用门店(多选),<c>location.Id</c>;与 Region 合并后写入 <c>fl_label_category_location</c>
  58 + /// 适用门店(多选),<c>location.Id</c>;可含 <c>ALL</c>;与 Region 合并后写入 <c>fl_label_category_location</c>
56 59 /// </summary>
57 60 public List<string>? LocationIds { get; set; }
58 61  
59   - public int OrderNum { get; set; }
  62 + /// <summary>排序;未传或 null 时默认 0</summary>
  63 + public int? OrderNum { get; set; }
60 64 }
61 65  
... ...
泰额版/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;
4 4  
5 5 public class LabelMultipleOptionCreateInputVo : ILabelEntityPartnerScopeInput
6 6 {
7   - /// <summary>多选项编码(可选;未传或空字符串时存空,列表/详情出参为「无」)</summary>
  7 + /// <summary>多选项编码(可选;未传或空字符串时由后端自动生成唯一编码)</summary>
8 8 public string? OptionCode { get; set; }
9 9  
10 10 public string OptionName { get; set; } = string.Empty;
... ... @@ -25,25 +25,27 @@ public class LabelMultipleOptionCreateInputVo : ILabelEntityPartnerScopeInput
25 25 public List<string>? CompanyIds { get; set; }
26 26  
27 27 /// <summary>
28   - /// 门店可用范围:ALL / SPECIFIED;传了 <see cref="RegionIds"/> 或 <see cref="LocationIds"/> 时自动为 SPECIFIED
  28 + /// 门店可用范围:ALL / SPECIFIED;传了 <see cref="RegionIds"/> 或 <see cref="LocationIds"/> 时自动为 SPECIFIED。
  29 + /// Id 数组可传哨兵 <c>ALL</c>(POST/PUT 新增与编辑均支持),归档为 <c>availabilityType=ALL</c> 且不写门店快照。
29 30 /// </summary>
30 31 public string AvailabilityType { get; set; } = "ALL";
31 32  
32 33 /// <summary>
33   - /// 适用 Region(多选),<c>fl_group.Id</c>;与 <see cref="GroupIds"/> 合并去重
  34 + /// 适用 Region(多选),<c>fl_group.Id</c>;与 <see cref="GroupIds"/> 合并去重。可传 <c>ALL</c>。
34 35 /// </summary>
35 36 public List<string>? RegionIds { get; set; }
36 37  
37 38 /// <summary>
38   - /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同
  39 + /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同。可传 <c>ALL</c>。
39 40 /// </summary>
40 41 public List<string>? GroupIds { get; set; }
41 42  
42 43 /// <summary>
43   - /// 适用门店(多选),<c>location.Id</c>;与 Region 合并后写入 <c>fl_label_multiple_option_location</c>
  44 + /// 适用门店(多选),<c>location.Id</c>;与 Region 合并后写入 <c>fl_label_multiple_option_location</c>。可传 <c>ALL</c>。
44 45 /// </summary>
45 46 public List<string>? LocationIds { get; set; }
46 47  
47   - public int OrderNum { get; set; }
  48 + /// <summary>排序;未传或 null 时默认 0</summary>
  49 + public int? OrderNum { get; set; }
48 50 }
49 51  
... ...
泰额版/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;
4 4  
5 5 public class LabelTypeCreateInputVo : ILabelEntityPartnerScopeInput
6 6 {
7   - public string TypeCode { get; set; } = string.Empty;
  7 + /// <summary>类型编码;为空时由后端自动生成唯一编码</summary>
  8 + public string? TypeCode { get; set; }
8 9  
9 10 public string TypeName { get; set; } = string.Empty;
10 11  
... ... @@ -22,25 +23,28 @@ public class LabelTypeCreateInputVo : ILabelEntityPartnerScopeInput
22 23 public List<string>? CompanyIds { get; set; }
23 24  
24 25 /// <summary>
25   - /// 门店可用范围:ALL / SPECIFIED;传了 <see cref="RegionIds"/> 或 <see cref="LocationIds"/> 时自动为 SPECIFIED
  26 + /// 门店可用范围:ALL / SPECIFIED。
  27 + /// <c>regionIds</c>/<c>groupIds</c>/<c>locationIds</c> 可传哨兵 <c>["ALL"]</c>(大小写不敏感);
  28 + /// 即使本字段为 SPECIFIED,Id 数组为 ALL 时仍归档为 ALL(POST/PUT 均支持)。
26 29 /// </summary>
27 30 public string AvailabilityType { get; set; } = "ALL";
28 31  
29 32 /// <summary>
30   - /// 适用 Region(多选),<c>fl_group.Id</c>;与 <see cref="GroupIds"/> 合并去重
  33 + /// 适用 Region(多选),<c>fl_group.Id</c>;与 <see cref="GroupIds"/> 合并去重;可含 <c>ALL</c>
31 34 /// </summary>
32 35 public List<string>? RegionIds { get; set; }
33 36  
34 37 /// <summary>
35   - /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同
  38 + /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c>
36 39 /// </summary>
37 40 public List<string>? GroupIds { get; set; }
38 41  
39 42 /// <summary>
40   - /// 适用门店(多选),<c>location.Id</c>;与 Region 合并后写入 <c>fl_label_type_location</c>
  43 + /// 适用门店(多选),<c>location.Id</c>;可含 <c>ALL</c>;与 Region 合并后写入 <c>fl_label_type_location</c>
41 44 /// </summary>
42 45 public List<string>? LocationIds { get; set; }
43 46  
44   - public int OrderNum { get; set; }
  47 + /// <summary>排序;未传或 null 时默认 0</summary>
  48 + public int? OrderNum { get; set; }
45 49 }
46 50  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportOnlineInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Product;
  2 +
  3 +/// <summary>
  4 +/// 产品 JSON 在线批量导入请求体
  5 +/// </summary>
  6 +public class ProductBatchImportOnlineInputVo
  7 +{
  8 + /// <summary>
  9 + /// 待导入行,每元素与单条 <c>POST /api/app/product</c> 的 <see cref="ProductCreateInputVo"/> 一致
  10 + /// </summary>
  11 + public List<ProductCreateInputVo> Items { get; set; } = new();
  12 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportOnlineResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Product;
  2 +
  3 +/// <summary>
  4 +/// 产品 JSON 在线批量导入结果
  5 +/// </summary>
  6 +public class ProductBatchImportOnlineResultDto
  7 +{
  8 + public int SuccessCount { get; set; }
  9 +
  10 + public int FailCount { get; set; }
  11 +
  12 + public List<ProductBatchImportOnlineErrorDto> Errors { get; set; } = new();
  13 +}
  14 +
  15 +/// <summary>
  16 +/// 产品 JSON 在线批量导入单条失败信息
  17 +/// </summary>
  18 +public class ProductBatchImportOnlineErrorDto
  19 +{
  20 + /// <summary>
  21 + /// 在请求 <c>items</c> 数组中的序号(从 0 开始)
  22 + /// </summary>
  23 + public int Index { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 产品名称(<c>productName</c>),便于定位失败行
  27 + /// </summary>
  28 + public string? ProductName { get; set; }
  29 +
  30 + public string Message { get; set; } = string.Empty;
  31 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs
1 1 using System.Collections.Generic;
  2 +using System.Text.Json.Serialization;
  3 +using FoodLabeling.Application.Contracts.Json;
2 4  
3 5 namespace FoodLabeling.Application.Contracts.Dtos.Product;
4 6  
... ... @@ -35,18 +37,46 @@ public class ProductCreateInputVo
35 37 public string? AvailabilityType { get; set; }
36 38  
37 39 /// <summary>
38   - /// 适用 Company(<c>fl_partner.Id</c>,UI 称 Company);展开该公司下全部门店后与 Region/门店合并写入 <c>fl_location_product</c>
  40 + /// 适用 Company:ALL / SPECIFIED(产品 Company 仅支持 SPECIFIED + 单选具体 Guid)。
39 41 /// </summary>
  42 + public string? AppliedPartnerType { get; set; }
  43 +
  44 + /// <summary>
  45 + /// 适用 Company(<c>fl_partner.Id</c>,UI 称 Company);展开该公司下全部门店后与 Region/门店合并写入 <c>fl_location_product</c>。
  46 + /// 与 <see cref="CompanyIds"/> 二选一或同值;产品 Company <b>仅支持单选</b>,<b>不支持 ALL</b>。
  47 + /// 兼容前端误传数组:<c>"partnerId":["{guid}"]</c> 与字符串等价。
  48 + /// </summary>
  49 + [JsonConverter(typeof(StringOrFirstArrayItemJsonConverter))]
40 50 public string? PartnerId { get; set; }
41 51  
42 52 /// <summary>
43   - /// 适用 Region(<c>fl_group.Id</c>,UI 称 Region;库字段为 <c>location.GroupName</c>)
  53 + /// 适用 Company Id 列表(与 <see cref="CompanyIds"/> 同义,前端常用字段)。<b>仅支持单选</b>。
  54 + /// </summary>
  55 + public List<string>? PartnerIds { get; set; }
  56 +
  57 + /// <summary>
  58 + /// 适用 Company(推荐前端字段)。<b>仅支持单选</b>:数组最多 1 个具体 Guid;<b>不支持 ALL</b>。
  59 + /// 传多个 Guid 或含 <c>ALL</c> 将报错;与 <see cref="PartnerId"/> 同时传时须一致。
  60 + /// </summary>
  61 + public List<string>? CompanyIds { get; set; }
  62 +
  63 + /// <summary>适用 Region:ALL / SPECIFIED(与 <see cref="AvailabilityType"/> 独立,用于前端显式声明)</summary>
  64 + public string? AppliedRegionType { get; set; }
  65 +
  66 + /// <summary>
  67 + /// 适用 Region(<c>fl_group.Id</c>,UI 称 Region;库字段为 <c>location.GroupName</c>)。
  68 + /// 与 <see cref="RegionIds"/> 合并;含 <c>ALL</c> 且无具体门店时,有具体 Company 则展开该公司门店,否则归档全局 ALL。
44 69 /// </summary>
45 70 public List<string>? GroupIds { get; set; }
46 71  
47 72 /// <summary>
48   - /// 适用门店 Id 列表;与 <see cref="GroupIds"/> 合并后写入 <c>fl_location_product</c>。
49   - /// 不传则不在本接口写入门店关联。
  73 + /// 适用 Region(与 <see cref="GroupIds"/> 同义,前端常用字段)。
  74 + /// </summary>
  75 + public List<string>? RegionIds { get; set; }
  76 +
  77 + /// <summary>
  78 + /// 适用门店 Id 列表。有具体 Guid 时以门店为准;含 <c>ALL</c> 且已指定 Company/Region 时表示该范围内全选(SPECIFIED 快照);
  79 + /// 无具体 Company/Region 时归档全局 ALL。
50 80 /// </summary>
51 81 public List<string>? LocationIds { get; set; }
52 82 }
... ...
泰额版/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
29 29 /// <summary>适用门店:ALL / SPECIFIED</summary>
30 30 public string AvailabilityType { get; set; } = "SPECIFIED";
31 31  
32   - /// <summary>适用 Company Id(<c>fl_partner.Id</c>,由关联门店反推;多公司时取第一个)</summary>
  32 + /// <summary>适用 Company Id(<c>fl_partner.Id</c>,由关联门店反推;多公司时取第一个;ALL 时为 null)</summary>
33 33 public string? PartnerId { get; set; }
34 34  
35   - /// <summary>适用 Company Id 列表(去重)</summary>
  35 + /// <summary>适用 Company Id 列表(去重;历史数据可能多条)</summary>
36 36 public List<string> PartnerIds { get; set; } = new();
37 37  
  38 + /// <summary>
  39 + /// 适用 Company(与编辑入参对齐)。单选:0~1 个具体 Guid;不支持 ALL。
  40 + /// </summary>
  41 + public List<string> CompanyIds { get; set; } = new();
  42 +
38 43 /// <summary>适用 Region Id(<c>fl_group.Id</c>,由关联门店反推)</summary>
39 44 public List<string> GroupIds { get; set; } = new();
40 45  
  46 + /// <summary>适用 Region(与 <see cref="GroupIds"/> 同义)</summary>
  47 + public List<string> RegionIds { get; set; } = new();
  48 +
41 49 /// <summary>
42   - /// 适用门店 Id 列表(来自 fl_location_product
  50 + /// 适用门店 Id 列表(来自 fl_location_product;覆盖 Region 全集时可折叠为 <c>["ALL"]</c>
43 51 /// </summary>
44 52 public List<string> LocationIds { get; set; } = new();
45 53 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs
1 1 namespace FoodLabeling.Application.Contracts.Dtos.ProductCategory;
2 2  
  3 +using FoodLabeling.Application.Contracts.Dtos.Common;
  4 +
3 5 /// <summary>
4 6 /// 产品模块:新增类别入参
5 7 /// </summary>
6   -public class ProductCategoryCreateInputVo
  8 +public class ProductCategoryCreateInputVo : ILabelEntityPartnerScopeInput
7 9 {
8   - /// <summary>
9   - /// 类别编码(可选,不传或空字符串表示无编码)
10   - /// </summary>
  10 + /// <summary>类别编码(可选;未传或空字符串时由后端自动生成唯一编码)</summary>
11 11 public string? CategoryCode { get; set; }
12 12  
13 13 public string CategoryName { get; set; } = string.Empty;
... ... @@ -30,25 +30,40 @@ public class ProductCategoryCreateInputVo
30 30 public bool State { get; set; } = true;
31 31  
32 32 /// <summary>
33   - /// 门店可用范围:ALL / SPECIFIED
  33 + /// 适用 Company:ALL / SPECIFIED。
  34 + /// <c>partnerIds</c>/<c>companyIds</c> 可传哨兵 <c>["ALL"]</c>(大小写不敏感)。
  35 + /// </summary>
  36 + public string? AppliedPartnerType { get; set; }
  37 +
  38 + /// <summary>适用 Company(<c>fl_partner.Id</c>)</summary>
  39 + public List<string>? PartnerIds { get; set; }
  40 +
  41 + /// <summary>与 <see cref="PartnerIds"/> 相同(兼容字段)</summary>
  42 + public List<string>? CompanyIds { get; set; }
  43 +
  44 + /// <summary>
  45 + /// 门店可用范围:ALL / SPECIFIED。
  46 + /// <c>regionIds</c>/<c>groupIds</c>/<c>locationIds</c> 可传哨兵 <c>["ALL"]</c>(大小写不敏感);
  47 + /// 即使本字段为 SPECIFIED,Id 数组为 ALL 时仍归档为 ALL(POST/PUT 均支持)。
34 48 /// </summary>
35 49 public string AvailabilityType { get; set; } = "ALL";
36 50  
37 51 /// <summary>
38   - /// 适用 Region(**多选**),<c>fl_group.Id</c> 数组;与 <see cref="GroupIds"/> 等价,推荐本字段
  52 + /// 适用 Region(**多选**),<c>fl_group.Id</c>;可含 <c>ALL</c>;与 <see cref="GroupIds"/> 合并去重
39 53 /// </summary>
40 54 public List<string>? RegionIds { get; set; }
41 55  
42 56 /// <summary>
43   - /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同;传任一会合并去重
  57 + /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c>
44 58 /// </summary>
45 59 public List<string>? GroupIds { get; set; }
46 60  
47 61 /// <summary>
48   - /// 适用门店(**多选**),<c>location.Id</c> 数组;与 Region 合并后写入 <c>fl_product_category_location</c>。
  62 + /// 适用门店(**多选**),<c>location.Id</c>;可含 <c>ALL</c>;与 Region 合并后写入 <c>fl_product_category_location</c>。
49 63 /// </summary>
50 64 public List<string>? LocationIds { get; set; }
51 65  
52   - public int OrderNum { get; set; } = 0;
  66 + /// <summary>排序;未传或 null 时默认 0</summary>
  67 + public int? OrderNum { get; set; }
53 68 }
54 69  
... ...
泰额版/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
21 21  
22 22 public string AvailabilityType { get; set; } = "ALL";
23 23  
  24 + public string AppliedPartnerType { get; set; } = "ALL";
  25 +
  26 + /// <summary>适用 Company 展示</summary>
  27 + public string Company { get; set; } = string.Empty;
  28 +
  29 + public List<string> PartnerIds { get; set; } = new();
  30 +
  31 + /// <summary>与 <see cref="PartnerIds"/> 相同</summary>
  32 + public List<string> CompanyIds { get; set; } = new();
  33 +
24 34 public int OrderNum { get; set; }
25 35  
26 36 public DateTime LastEdited { get; set; }
... ...
泰额版/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
22 22  
23 23 public string AvailabilityType { get; set; } = "ALL";
24 24  
  25 + public string AppliedPartnerType { get; set; } = "ALL";
  26 +
  27 + /// <summary>列表/详情 Company 展示</summary>
  28 + public string Company { get; set; } = string.Empty;
  29 +
  30 + public List<string> PartnerIds { get; set; } = new();
  31 +
  32 + /// <summary>与 <see cref="PartnerIds"/> 相同</summary>
  33 + public List<string> CompanyIds { get; set; } = new();
  34 +
25 35 /// <summary>适用 Region Id 列表(多选,<c>fl_group.Id</c>;由绑定门店反推)</summary>
26 36 public List<string> RegionIds { get; set; } = new();
27 37  
... ...
泰额版/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;
9 9 public class ProductLocationGetListInputVo : PagedAndSortedResultRequestDto
10 10 {
11 11 /// <summary>
  12 + /// Company Id(<c>fl_partner.Id</c>);未传 <see cref="LocationId"/> 时按该公司下门店展开,
  13 + /// 并包含 <c>AvailabilityType=ALL</c> 的产品。
  14 + /// </summary>
  15 + public string? PartnerId { get; set; }
  16 +
  17 + /// <summary>
12 18 /// 门店Id(location.Id,string 表示)
13 19 /// </summary>
14 20 public string? LocationId { get; set; }
... ... @@ -18,4 +24,3 @@ public class ProductLocationGetListInputVo : PagedAndSortedResultRequestDto
18 24 /// </summary>
19 25 public string? ProductId { get; set; }
20 26 }
21   -
... ...
泰额版/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
24 24 public int? OrderNum { get; set; }
25 25  
26 26 /// <summary>
27   - /// 绑定菜单 Id;与 accessPermissions 同时传时以本字段为准
  27 + /// 绑定菜单 Id;与 menuPermissionKeys / accessPermissions 同时传时以本字段为准
28 28 /// </summary>
29 29 public List<Guid>? MenuIds { get; set; }
30 30  
31 31 /// <summary>
  32 + /// 绑定菜单 Id(字符串 Guid 列表);与 <see cref="MenuIds"/> 等价,<see cref="MenuIds"/> 优先
  33 + /// </summary>
  34 + public List<string>? MenuPermissionKeys { get; set; }
  35 +
  36 + /// <summary>
32 37 /// 按 PermissionCode 绑定菜单:JSON 数组字符串(如 <c>["manage_labels"]</c>)、英文逗号分隔;传空字符串表示清空绑定;不传则不修改已有绑定(仅编辑时)
33 38 /// </summary>
34 39 public string? AccessPermissions { get; set; }
... ...
泰额版/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
28 28 /// 角色访问权限码列表(与库字段 AccessPermissionCodes 对应)
29 29 /// </summary>
30 30 public List<string> AccessPermissionCodes { get; set; } = new();
  31 +
  32 + /// <summary>
  33 + /// 已绑定菜单 Id 列表(字符串 Guid,与前端 menuPermissionKeys 字段一致)
  34 + /// </summary>
  35 + public List<string> MenuPermissionKeys { get; set; } = new();
31 36 }
32 37  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportOnlineInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +/// <summary>
  4 +/// 成员 JSON 在线批量导入请求体
  5 +/// </summary>
  6 +public class TeamMemberBatchImportOnlineInputVo
  7 +{
  8 + /// <summary>
  9 + /// 待导入行,每元素与单条 <c>POST /api/app/team-member</c> 的 <see cref="TeamMemberCreateInputVo"/> 一致
  10 + /// </summary>
  11 + public List<TeamMemberCreateInputVo> Items { get; set; } = new();
  12 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportOnlineResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +/// <summary>
  4 +/// 成员 JSON 在线批量导入结果
  5 +/// </summary>
  6 +public class TeamMemberBatchImportOnlineResultDto
  7 +{
  8 + public int SuccessCount { get; set; }
  9 +
  10 + public int FailCount { get; set; }
  11 +
  12 + public List<TeamMemberBatchImportOnlineErrorDto> Errors { get; set; } = new();
  13 +}
  14 +
  15 +/// <summary>
  16 +/// 成员 JSON 在线批量导入单条失败信息
  17 +/// </summary>
  18 +public class TeamMemberBatchImportOnlineErrorDto
  19 +{
  20 + /// <summary>
  21 + /// 在请求 <c>items</c> 数组中的序号(从 0 开始)
  22 + /// </summary>
  23 + public int Index { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 登录账号(<c>userName</c>),便于定位失败行
  27 + /// </summary>
  28 + public string? UserName { get; set; }
  29 +
  30 + public string Message { get; set; } = string.Empty;
  31 +}
... ...
泰额版/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
28 28 public List<string>? PartnerIds { get; set; }
29 29  
30 30 /// <summary>
31   - /// 适用 Region 多选(<c>fl_group.Id</c>);Company Admin 仅传 Company 时可省略,后端自动绑定该公司下全部 Region 与门店
  31 + /// 适用 Region 多选(<c>fl_group.Id</c>);可含 <c>ALL</c> 哨兵(大小写不敏感,与具体 Guid 同传时以 ALL 为准)。
  32 + /// Company Admin 仅传 Company 时可省略;<c>locationIds</c> 为空且含 ALL 时展开该公司全部 Region 下门店。
32 33 /// </summary>
33 34 public List<string>? RegionIds { get; set; }
34 35  
35 36 /// <summary>
36   - /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同)
  37 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同);可含 <c>ALL</c>。
37 38 /// </summary>
38 39 public List<string>? GroupIds { get; set; }
39 40  
40 41 /// <summary>
41   - /// 适用门店多选(<c>location.Id</c>);Company Admin 仅传 Company 时可省略,与 Region 合并后写入 <c>userlocation</c>
  42 + /// 适用门店多选(<c>location.Id</c>);可含 <c>ALL</c> 哨兵。
  43 + /// 有具体 Region 时展开该 Region 下门店;仅有 Company 时展开该公司全部门店。
  44 + /// Company Admin 仅传 Company 时可省略。
42 45 /// </summary>
43 46 public List<string>? LocationIds { get; set; }
44 47  
  48 + /// <summary>
  49 + /// 适用门店多选别名(与 <see cref="LocationIds"/> 合并解析);可含 <c>ALL</c> 或门店 Guid。
  50 + /// </summary>
  51 + public List<string>? Locations { get; set; }
  52 +
45 53 public bool State { get; set; } = true;
46 54 }
47 55  
... ...
泰额版/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
19 19 /// <summary>适用 Company Id(多选,由绑定门店反推)</summary>
20 20 public List<string> PartnerIds { get; set; } = new();
21 21  
22   - /// <summary>适用 Region Id(多选,<c>fl_group.Id</c>)</summary>
  22 + /// <summary>适用 Region Id(多选,<c>fl_group.Id</c>;覆盖该公司全部 Region 时回显 <c>["ALL"]</c>)</summary>
23 23 public List<string> RegionIds { get; set; } = new();
24 24  
25 25 /// <summary>与 <see cref="RegionIds"/> 相同</summary>
26 26 public List<string> GroupIds { get; set; } = new();
27 27  
  28 + /// <summary>绑定门店 Id;覆盖该公司全部门店时回显 <c>["ALL"]</c>(库中仍存展开 Guid)</summary>
28 29 public List<string> LocationIds { get; set; } = new();
29 30  
30 31 public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new();
... ...
泰额版/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
28 28 public List<string>? PartnerIds { get; set; }
29 29  
30 30 /// <summary>
31   - /// 适用 Region 多选(<c>fl_group.Id</c>);Company Admin 仅传 Company 时可省略
  31 + /// 适用 Region 多选(<c>fl_group.Id</c>);可含 <c>ALL</c> 哨兵(大小写不敏感,与具体 Guid 同传时以 ALL 为准)。
32 32 /// </summary>
33 33 public List<string>? RegionIds { get; set; }
34 34  
35 35 /// <summary>
36   - /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同)
  36 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同);可含 <c>ALL</c>。
37 37 /// </summary>
38 38 public List<string>? GroupIds { get; set; }
39 39  
40 40 /// <summary>
41   - /// 适用门店多选(<c>location.Id</c>);Company Admin 仅传 Company 时可省略
  41 + /// 适用门店多选(<c>location.Id</c>);可含 <c>ALL</c> 哨兵。
  42 + /// 有具体 Region 时表示该 Region 下全部门店;仅有 Company 时按公司全部门店落库。
42 43 /// </summary>
43 44 public List<string>? LocationIds { get; set; }
44 45  
  46 + /// <summary>
  47 + /// 适用门店多选别名(与 <see cref="LocationIds"/> 合并解析);可含 <c>ALL</c> 或门店 Guid。
  48 + /// </summary>
  49 + public List<string>? Locations { get; set; }
  50 +
45 51 public bool State { get; set; } = true;
46 52 }
47 53  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryCreateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class TrainingCategoryCreateInputVo
  4 +{
  5 + public string CategoryName { get; set; } = string.Empty;
  6 +
  7 + /// <summary>空=一级分类;有值=二级分类</summary>
  8 + public string? ParentId { get; set; }
  9 +
  10 + public int OrderNum { get; set; }
  11 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryGetOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class TrainingCategoryGetOutputDto
  4 +{
  5 + public string Id { get; set; } = string.Empty;
  6 +
  7 + public string CategoryName { get; set; } = string.Empty;
  8 +
  9 + public string? ParentId { get; set; }
  10 +
  11 + public int OrderNum { get; set; }
  12 +
  13 + public DateTime CreationTime { get; set; }
  14 +
  15 + public DateTime? LastModificationTime { get; set; }
  16 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryTreeInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class TrainingCategoryTreeInputVo
  4 +{
  5 + /// <summary>关键字(匹配分类名或文件名)</summary>
  6 + public string? Keyword { get; set; }
  7 +
  8 + /// <summary>按门店筛选可见文件;不传则不过滤文件权限</summary>
  9 + public string? LocationId { get; set; }
  10 +
  11 + /// <summary>是否包含文件列表,默认 true</summary>
  12 + public bool IncludeFiles { get; set; } = true;
  13 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryTreeNodeDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class TrainingCategoryTreeNodeDto
  4 +{
  5 + public string Id { get; set; } = string.Empty;
  6 +
  7 + public string CategoryName { get; set; } = string.Empty;
  8 +
  9 + public string? ParentId { get; set; }
  10 +
  11 + public int OrderNum { get; set; }
  12 +
  13 + public List<TrainingCategoryTreeNodeDto> Children { get; set; } = new();
  14 +
  15 + public List<TrainingFileDto> Files { get; set; } = new();
  16 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingCategoryUpdateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class TrainingCategoryUpdateInputVo
  4 +{
  5 + public string CategoryName { get; set; } = string.Empty;
  6 +
  7 + public int OrderNum { get; set; }
  8 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class TrainingFileDto
  4 +{
  5 + public string Id { get; set; } = string.Empty;
  6 +
  7 + public string CategoryId { get; set; } = string.Empty;
  8 +
  9 + public string FileName { get; set; } = string.Empty;
  10 +
  11 + public string FileUrl { get; set; } = string.Empty;
  12 +
  13 + /// <summary>image / doc / other</summary>
  14 + public string FileType { get; set; } = "other";
  15 +
  16 + public long FileSize { get; set; }
  17 +
  18 + public int OrderNum { get; set; }
  19 +
  20 + public string AppliedPartnerType { get; set; } = "ALL";
  21 +
  22 + /// <summary>Company 展示(ALL 时为 All Companies)</summary>
  23 + public string Company { get; set; } = string.Empty;
  24 +
  25 + public List<string> PartnerIds { get; set; } = new();
  26 +
  27 + /// <summary>与 <see cref="PartnerIds"/> 相同</summary>
  28 + public List<string> CompanyIds { get; set; } = new();
  29 +
  30 + public string AppliedRegionType { get; set; } = "ALL";
  31 +
  32 + /// <summary>Region 展示</summary>
  33 + public string Region { get; set; } = string.Empty;
  34 +
  35 + public List<string> RegionIds { get; set; } = new();
  36 +
  37 + /// <summary>与 <see cref="RegionIds"/> 相同</summary>
  38 + public List<string> GroupIds { get; set; } = new();
  39 +
  40 + public string AvailabilityType { get; set; } = "ALL";
  41 +
  42 + /// <summary>Location 展示</summary>
  43 + public string Location { get; set; } = string.Empty;
  44 +
  45 + public List<string> LocationIds { get; set; } = new();
  46 +
  47 + public DateTime CreationTime { get; set; }
  48 +
  49 + public DateTime? LastModificationTime { get; set; }
  50 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileScopeInputVo.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +
  3 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  4 +
  5 +public class TrainingFileScopeInputVo : ITrainingFileScopeInput
  6 +{
  7 + public string? AppliedPartnerType { get; set; }
  8 +
  9 + public List<string>? PartnerIds { get; set; }
  10 +
  11 + public List<string>? CompanyIds { get; set; }
  12 +
  13 + /// <summary>适用 Region:ALL / SPECIFIED</summary>
  14 + public string? AppliedRegionType { get; set; }
  15 +
  16 + public List<string>? RegionIds { get; set; }
  17 +
  18 + public List<string>? GroupIds { get; set; }
  19 +
  20 + /// <summary>适用 Location:ALL / SPECIFIED</summary>
  21 + public string? AvailabilityType { get; set; }
  22 +
  23 + /// <summary>适用 Location:ALL / SPECIFIED(<see cref="AvailabilityType"/> 别名)</summary>
  24 + public string? AppliedLocationType { get; set; }
  25 +
  26 + public List<string>? LocationIds { get; set; }
  27 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileScopeOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class TrainingFileScopeOutputDto
  4 +{
  5 + public string AppliedPartnerType { get; set; } = "ALL";
  6 +
  7 + public string Company { get; set; } = string.Empty;
  8 +
  9 + public List<string> PartnerIds { get; set; } = new();
  10 +
  11 + public List<string> CompanyIds { get; set; } = new();
  12 +
  13 + public string AppliedRegionType { get; set; } = "ALL";
  14 +
  15 + public string Region { get; set; } = string.Empty;
  16 +
  17 + public List<string> RegionIds { get; set; } = new();
  18 +
  19 + public List<string> GroupIds { get; set; } = new();
  20 +
  21 + public string AvailabilityType { get; set; } = "ALL";
  22 +
  23 + public string Location { get; set; } = string.Empty;
  24 +
  25 + public List<string> LocationIds { get; set; } = new();
  26 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileSortInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class TrainingFileSortInputVo
  4 +{
  5 + public List<TrainingFileSortItemVo> Items { get; set; } = new();
  6 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileSortItemVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class TrainingFileSortItemVo
  4 +{
  5 + public string Id { get; set; } = string.Empty;
  6 +
  7 + public int OrderNum { get; set; }
  8 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUpdateInputVo.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +
  3 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  4 +
  5 +public class TrainingFileUpdateInputVo : ITrainingFileScopeInput
  6 +{
  7 + public string FileName { get; set; } = string.Empty;
  8 +
  9 + public int OrderNum { get; set; }
  10 +
  11 + /// <summary>适用 Company:ALL / SPECIFIED</summary>
  12 + public string? AppliedPartnerType { get; set; }
  13 +
  14 + /// <summary>适用 Company(<c>fl_partner.Id</c>);可含 <c>ALL</c></summary>
  15 + public List<string>? PartnerIds { get; set; }
  16 +
  17 + /// <summary>与 <see cref="PartnerIds"/> 相同;可含 <c>ALL</c></summary>
  18 + public List<string>? CompanyIds { get; set; }
  19 +
  20 + /// <summary>适用 Region:ALL / SPECIFIED</summary>
  21 + public string? AppliedRegionType { get; set; }
  22 +
  23 + /// <summary>适用 Region(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并;可含 <c>ALL</c></summary>
  24 + public List<string>? RegionIds { get; set; }
  25 +
  26 + /// <summary>与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c></summary>
  27 + public List<string>? GroupIds { get; set; }
  28 +
  29 + /// <summary>适用 Location:ALL / SPECIFIED</summary>
  30 + public string? AvailabilityType { get; set; }
  31 +
  32 + /// <summary>适用 Location:ALL / SPECIFIED(<see cref="AvailabilityType"/> 别名)</summary>
  33 + public string? AppliedLocationType { get; set; }
  34 +
  35 + /// <summary>适用门店(<c>location.Id</c>);可含 <c>ALL</c></summary>
  36 + public List<string>? LocationIds { get; set; }
  37 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUploadInputVo.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +using Microsoft.AspNetCore.Http;
  3 +using Microsoft.AspNetCore.Mvc;
  4 +
  5 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  6 +
  7 +public class TrainingFileUploadInputVo : ITrainingFileScopeInput
  8 +{
  9 + [FromForm(Name = "file")]
  10 + public IFormFile File { get; set; } = default!;
  11 +
  12 + [FromForm(Name = "categoryId")]
  13 + public string CategoryId { get; set; } = string.Empty;
  14 +
  15 + [FromForm(Name = "orderNum")]
  16 + public int OrderNum { get; set; }
  17 +
  18 + /// <summary>适用 Company:ALL / SPECIFIED</summary>
  19 + [FromForm(Name = "appliedPartnerType")]
  20 + public string? AppliedPartnerType { get; set; }
  21 +
  22 + /// <summary>适用 Company(<c>fl_partner.Id</c>);可含 <c>ALL</c></summary>
  23 + [FromForm(Name = "partnerIds")]
  24 + public List<string>? PartnerIds { get; set; }
  25 +
  26 + /// <summary>与 <see cref="PartnerIds"/> 相同;可含 <c>ALL</c></summary>
  27 + [FromForm(Name = "companyIds")]
  28 + public List<string>? CompanyIds { get; set; }
  29 +
  30 + /// <summary>适用 Region:ALL / SPECIFIED</summary>
  31 + [FromForm(Name = "appliedRegionType")]
  32 + public string? AppliedRegionType { get; set; }
  33 +
  34 + /// <summary>适用 Region(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并;可含 <c>ALL</c></summary>
  35 + [FromForm(Name = "regionIds")]
  36 + public List<string>? RegionIds { get; set; }
  37 +
  38 + /// <summary>与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c></summary>
  39 + [FromForm(Name = "groupIds")]
  40 + public List<string>? GroupIds { get; set; }
  41 +
  42 + /// <summary>适用 Location:ALL / SPECIFIED</summary>
  43 + [FromForm(Name = "availabilityType")]
  44 + public string? AvailabilityType { get; set; }
  45 +
  46 + /// <summary>适用 Location:ALL / SPECIFIED(<see cref="AvailabilityType"/> 别名)</summary>
  47 + [FromForm(Name = "appliedLocationType")]
  48 + public string? AppliedLocationType { get; set; }
  49 +
  50 + /// <summary>适用门店(<c>location.Id</c>);可含 <c>ALL</c></summary>
  51 + [FromForm(Name = "locationIds")]
  52 + public List<string>? LocationIds { get; set; }
  53 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUploadOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class TrainingFileUploadOutputDto
  4 +{
  5 + public string Id { get; set; } = string.Empty;
  6 +
  7 + public string FileName { get; set; } = string.Empty;
  8 +
  9 + public string FileUrl { get; set; } = string.Empty;
  10 +
  11 + public string FileType { get; set; } = "other";
  12 +
  13 + public long FileSize { get; set; }
  14 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/UsAppTrainingTreeInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Training;
  2 +
  3 +public class UsAppTrainingTreeInputVo
  4 +{
  5 + public string LocationId { get; set; } = string.Empty;
  6 +
  7 + public string? Keyword { get; set; }
  8 +}
... ...
泰额版/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
12 12 /// 获取当前登录用户的角色编码、权限码与可见菜单树
13 13 /// </summary>
14 14 /// <remarks>
15   - /// 与框架 <c>UserManager.GetInfoAsync</c> 一致;用户名为 <c>admin</c> 时返回全部未删除菜单(与 <c>AccountService.GetVue3Router</c> 行为对齐)。
16   - /// 返回体额外包含:<c>lastUpdated</c>(系统编辑全局时间戳,与任意写接口成功联动;无戳时回退用户 LastModificationTime)、<c>role</c>(角色展示名,多角色英文逗号拼接)、<c>fullName</c>(姓名优先,其次昵称、用户名)。
  15 + /// 非 SaaS(<c>EnabledSaasMultiTenancy=false</c>)且用户名为 <c>admin</c> 时返回全部未删除菜单(与 <c>AccountService.GetVue3Router</c> 行为对齐)。
  16 + /// SaaS 开启时:平台主库登录(JWT / <c>__tenant</c> 无业务租户 Id,含全 0)即使用户名为 <c>admin</c> 也仅返回平台菜单
  17 + /// (<c>PermissionCode</c> 以 <c>menu.platform</c> 开头或 <c>Router</c> 以 <c>/platform</c> 开头,含祖先节点以成树);
  18 + /// 公司业务租户登录时即使用户名为 <c>admin</c> 也按 RoleMenu 关联查询,与 <c>UpdateCompanyMenus</c> 同步的管理员菜单权限一致。
  19 + /// 返回体额外包含:<c>userId</c>(当前登录用户 Id,与 <c>user.id</c> 一致,非租户 Id)、<c>lastUpdated</c>(系统编辑全局时间戳,与任意写接口成功联动;无戳时回退用户 LastModificationTime)、<c>role</c>(角色展示名,多角色英文逗号拼接)、<c>fullName</c>(姓名优先,其次昵称、用户名)。
17 20 /// 角色名通过 <c>Role</c> 表直查(<c>RoleDbEntity</c>),避免走仓储 IDataPermission。
18 21 /// </remarks>
19 22 /// <returns>用户简要信息、权限码与菜单树</returns>
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelAlertTimerAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +using FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer;
  3 +using Volo.Abp.Application.Services;
  4 +
  5 +namespace FoodLabeling.Application.Contracts.IServices;
  6 +
  7 +/// <summary>
  8 +/// 标签告警计时器(App)
  9 +/// </summary>
  10 +public interface ILabelAlertTimerAppService : IApplicationService
  11 +{
  12 + Task<PagedResultWithPageDto<LabelAlertTimerListItemDto>> GetListAsync(LabelAlertTimerGetListInputVo input);
  13 +
  14 + /// <summary>
  15 + /// App:当前账号当前门店告警列表(含倒计时;locationId 可省略,走已选门店缓存)
  16 + /// </summary>
  17 + Task<PagedResultWithPageDto<LabelAlertTimerListItemDto>> GetAppListAsync(LabelAlertTimerGetListInputVo input);
  18 +
  19 + Task DeleteAsync(string id);
  20 +
  21 + Task<LabelAlertTimerCheckExpiredOutputDto> CheckExpiredAsync(LabelAlertTimerCheckExpiredInputVo input);
  22 +}
... ...
泰额版/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
21 21 Task<LabelGetOutputDto> GetAsync(string id);
22 22  
23 23 /// <summary>
24   - /// 新增标签。Body 支持 <c>appliedRegionType</c>(ALL/SPECIFIED)、<c>regionIds</c> / <c>groupIds</c>(落库 <c>fl_label_region</c>)、<c>locationIds</c>(主字段,落库 <c>fl_label_location</c>);<c>locationId</c> 与 <c>locationIds</c> 合并;<c>labelTypeId</c> 可选。
  24 + /// 新增标签。Body 支持 <c>appliedRegionType</c>(ALL/SPECIFIED)、
  25 + /// <c>regionIds</c> / <c>groupIds</c> / <c>locationIds</c>(可含哨兵 <c>ALL</c>,归档为 AppliedRegionType=ALL)。
25 26 /// </summary>
26 27 Task<LabelGetOutputDto> CreateAsync(LabelCreateInputVo input);
27 28  
... ... @@ -31,7 +32,7 @@ public interface ILabelAppService : IApplicationService
31 32 Task<LabelBatchCreateResultDto> BatchCreateAsync(LabelBatchCreateInputVo input);
32 33  
33 34 /// <summary>
34   - /// 编辑标签(id=LabelCode)。适用 Region / 门店字段同 <see cref="CreateAsync"/>。
  35 + /// 编辑标签(id=LabelCode)。范围传参与 <see cref="CreateAsync"/> 一致,支持 Id 数组传 <c>ALL</c>。
35 36 /// </summary>
36 37 Task<LabelGetOutputDto> UpdateAsync(string id, LabelUpdateInputVo input);
37 38  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelCategoryAppService.cs
... ... @@ -9,8 +9,10 @@ public interface ILabelCategoryAppService
9 9  
10 10 Task<LabelCategoryGetOutputDto> GetAsync(string id);
11 11  
  12 + /// <summary>新增标签分类;<c>regionIds</c>/<c>groupIds</c>/<c>locationIds</c> 支持哨兵 <c>ALL</c>。</summary>
12 13 Task<LabelCategoryGetOutputDto> CreateAsync(LabelCategoryCreateInputVo input);
13 14  
  15 + /// <summary>编辑标签分类;范围传参与新增一致,支持 <c>ALL</c>。</summary>
14 16 Task<LabelCategoryGetOutputDto> UpdateAsync(string id, LabelCategoryUpdateInputVo input);
15 17  
16 18 Task DeleteAsync(string id);
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelMultipleOptionAppService.cs
... ... @@ -9,8 +9,14 @@ public interface ILabelMultipleOptionAppService
9 9  
10 10 Task<LabelMultipleOptionGetOutputDto> GetAsync(string id);
11 11  
  12 + /// <summary>
  13 + /// 新增多选项;<c>regionIds</c>、<c>groupIds</c>、<c>locationIds</c> 可传 <c>ALL</c> 哨兵(POST)。
  14 + /// </summary>
12 15 Task<LabelMultipleOptionGetOutputDto> CreateAsync(LabelMultipleOptionCreateInputVo input);
13 16  
  17 + /// <summary>
  18 + /// 编辑多选项;适用范围与新增相同,<c>regionIds</c>、<c>groupIds</c>、<c>locationIds</c> 可传 <c>ALL</c>(PUT)。
  19 + /// </summary>
14 20 Task<LabelMultipleOptionGetOutputDto> UpdateAsync(string id, LabelMultipleOptionUpdateInputVo input);
15 21  
16 22 Task DeleteAsync(string id);
... ...
泰额版/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
25 25  
26 26 /// <summary>
27 27 /// 新增标签模板;body 支持 Company/Region/Location 三维范围(各维度 ALL/SPECIFIED + Id 数组)。
  28 + /// <c>regionIds</c>、<c>groupIds</c>、<c>locationIds</c>、<c>appliedLocationIds</c> 可传 <c>ALL</c> 哨兵(POST)。
28 29 /// </summary>
29 30 Task<LabelTemplateGetOutputDto> CreateAsync(LabelTemplateCreateInputVo input);
30 31  
31 32 /// <summary>
32 33 /// 编辑标签模板(版本号 +1,重建 elements);适用范围多选规则同新增。
33 34 /// body 支持 <c>printOrientation</c>(<c>vertical</c> / <c>horizontal</c>,横打不交换 Width/Height)。
  35 + /// <c>regionIds</c>、<c>groupIds</c>、<c>locationIds</c>、<c>appliedLocationIds</c> 可传 <c>ALL</c> 哨兵(PUT)。
34 36 /// </summary>
35 37 Task<LabelTemplateGetOutputDto> UpdateAsync(string id, LabelTemplateUpdateInputVo input);
36 38  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTypeAppService.cs
... ... @@ -9,8 +9,10 @@ public interface ILabelTypeAppService
9 9  
10 10 Task<LabelTypeGetOutputDto> GetAsync(string id);
11 11  
  12 + /// <summary>新增标签类型;<c>regionIds</c>/<c>groupIds</c>/<c>locationIds</c> 支持哨兵 <c>ALL</c>。</summary>
12 13 Task<LabelTypeGetOutputDto> CreateAsync(LabelTypeCreateInputVo input);
13 14  
  15 + /// <summary>编辑标签类型;范围传参与新增一致,支持 <c>ALL</c>。</summary>
14 16 Task<LabelTypeGetOutputDto> UpdateAsync(string id, LabelTypeUpdateInputVo input);
15 17  
16 18 Task DeleteAsync(string id);
... ...
泰额版/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
32 32 /// </summary>
33 33 /// <remarks>
34 34 /// <see cref="ProductCreateInputVo.ProductCode"/> 可选;为空时后端生成唯一编码(如 PRD_ + Guid)。
35   - /// 若传 <see cref="ProductCreateInputVo.PartnerId"/>(Company)、<see cref="ProductCreateInputVo.GroupIds"/>(Region)
36   - /// 和/或 <see cref="ProductCreateInputVo.LocationIds"/>,合并后写入 fl_location_product。
  35 + /// Company 传 <see cref="ProductCreateInputVo.CompanyIds"/>(推荐,仅单选具体 Guid,<b>不支持 ALL</b>)
  36 + /// 或 <see cref="ProductCreateInputVo.PartnerId"/>;
  37 + /// 再与 <see cref="ProductCreateInputVo.GroupIds"/> / <see cref="ProductCreateInputVo.LocationIds"/> 合并写入 fl_location_product。
  38 + /// Region/Location 数组含 <c>["ALL"]</c> 时,即使 AvailabilityType 为 SPECIFIED 也归档为 ALL,清空 fl_location_product 快照。
37 39 /// </remarks>
38 40 Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input);
39 41  
... ... @@ -41,8 +43,8 @@ public interface IProductAppService : IApplicationService
41 43 /// 编辑产品
42 44 /// </summary>
43 45 /// <remarks>
44   - /// 当请求体包含 <see cref="ProductCreateInputVo.PartnerId"/>、<see cref="ProductCreateInputVo.GroupIds"/>
45   - /// 和/或 <see cref="ProductCreateInputVo.LocationIds"/> 时,合并后整表替换门店关联;均不传则不改。
  46 + /// 当请求体包含 <c>companyIds</c>/<c>partnerId</c>、<c>groupIds</c> 和/或 <c>locationIds</c> 时,合并后整表替换门店关联;均不传则不改。
  47 + /// Company <b>仅支持单选</b>(<c>companyIds</c> 最多 1 个具体 Guid,<b>不支持 ALL</b>)。Region/Location ALL 哨兵规则同 <see cref="CreateAsync"/>。
46 48 /// </remarks>
47 49 Task<ProductGetOutputDto> UpdateAsync(Guid id, ProductUpdateInputVo input);
48 50  
... ... @@ -74,5 +76,36 @@ public interface IProductAppService : IApplicationService
74 76 /// 批量编辑产品(JSON 一次提交多行,与单条 <c>PUT</c> 字段一致)
75 77 /// </summary>
76 78 Task<ProductBulkUpdateResultDto> UpdateProductsBulkAsync(ProductBulkUpdateInputVo input);
  79 +
  80 + /// <summary>
  81 + /// JSON 在线批量导入产品(逐行调用 <see cref="CreateAsync"/>,部分成功)
  82 + /// </summary>
  83 + /// <remarks>
  84 + /// 请求体为 JSON,每行字段与单条新增 <see cref="ProductCreateInputVo"/> 一致。
  85 + ///
  86 + /// 示例请求:
  87 + /// ```json
  88 + /// {
  89 + /// "items": [
  90 + /// {
  91 + /// "productName": "Tuna &amp; Bacon Sub",
  92 + /// "categoryId": "CATEGORY_ID",
  93 + /// "productCode": "40001",
  94 + /// "state": true,
  95 + /// "locationIds": ["LOCATION_GUID"]
  96 + /// }
  97 + /// ]
  98 + /// }
  99 + /// ```
  100 + ///
  101 + /// 参数说明:
  102 + /// - items: 待导入行数组;<c>index</c> 从 0 起;单次最多 <c>MaxImportRows</c> 条(默认 5000)
  103 + /// </remarks>
  104 + /// <param name="input">批量导入请求体</param>
  105 + /// <returns>成功数、失败数及失败明细(<c>index</c>、<c>productName</c>、<c>message</c>)</returns>
  106 + /// <response code="200">全部或部分行处理完成,见返回体中的计数与 errors</response>
  107 + /// <response code="400">整单校验失败(如 items 为空、超过单次条数上限)</response>
  108 + /// <response code="500">服务器错误</response>
  109 + Task<ProductBatchImportOnlineResultDto> BatchImportOnlineAsync(ProductBatchImportOnlineInputVo input);
77 110 }
78 111  
... ...
泰额版/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
17 17 Task<ProductCategoryGetOutputDto> GetAsync(string id);
18 18  
19 19 /// <summary>
20   - /// 新增类别;<c>categoryCode</c> 可选;body 传 <c>regionIds</c>(Region 多选)与 <c>locationIds</c>(门店多选)绑定适用范围
  20 + /// 新增类别;支持 <c>companyIds</c>/<c>partnerIds</c> 与 Region/Location 哨兵 <c>ALL</c>(POST)
21 21 /// </summary>
22 22 Task<ProductCategoryGetOutputDto> CreateAsync(ProductCategoryCreateInputVo input);
23 23  
24 24 /// <summary>
25   - /// 编辑类别;<c>categoryCode</c> 可选;<c>regionIds</c>/<c>locationIds</c> 多选数组规则同新增;传空数组 <c>[]</c> 可清空对应范围
  25 + /// 编辑类别;范围传参与新增一致,<c>companyIds</c> 全选回显/保存支持 <c>["ALL"]</c>(PUT)
26 26 /// </summary>
27 27 Task<ProductCategoryGetOutputDto> UpdateAsync(string id, ProductCategoryUpdateInputVo input);
28 28  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs
... ... @@ -14,8 +14,14 @@ public interface ITeamMemberAppService
14 14  
15 15 Task<TeamMemberGetOutputDto> GetAsync(Guid id);
16 16  
  17 + /// <summary>
  18 + /// 新增成员(POST <c>/api/app/team-member</c>)。<c>locationIds</c> / <c>regionIds</c> / <c>groupIds</c> / <c>locations</c> 可传 <c>ALL</c> 哨兵。
  19 + /// </summary>
17 20 Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input);
18 21  
  22 + /// <summary>
  23 + /// 更新成员(PUT <c>/api/app/team-member/{id}</c>)。范围传参规则与 <see cref="CreateAsync"/> 相同。
  24 + /// </summary>
19 25 Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input);
20 26  
21 27 Task DeleteAsync(Guid id);
... ... @@ -39,4 +45,37 @@ public interface ITeamMemberAppService
39 45 /// 批量编辑成员(JSON 一次提交多行)
40 46 /// </summary>
41 47 Task<TeamMemberBulkUpdateResultDto> UpdateTeamMembersBulkAsync(TeamMemberBulkUpdateInputVo input);
  48 +
  49 + /// <summary>
  50 + /// JSON 在线批量导入成员(逐行调用 <see cref="CreateAsync"/>,部分成功)
  51 + /// </summary>
  52 + /// <remarks>
  53 + /// 请求体为 JSON,每行字段与单条新增 <see cref="TeamMemberCreateInputVo"/> 一致;
  54 + /// <c>password</c> 为空时使用配置 <c>TeamMemberImportDefaultPassword</c>。
  55 + ///
  56 + /// 示例请求:
  57 + /// ```json
  58 + /// {
  59 + /// "items": [
  60 + /// {
  61 + /// "fullName": "John Doe",
  62 + /// "userName": "john@example.com",
  63 + /// "email": "john@example.com",
  64 + /// "roleId": "ROLE_GUID",
  65 + /// "locationIds": ["LOCATION_GUID"],
  66 + /// "state": true
  67 + /// }
  68 + /// ]
  69 + /// }
  70 + /// ```
  71 + ///
  72 + /// 参数说明:
  73 + /// - items: 待导入行数组;<c>index</c> 从 0 起;单次最多 <c>MaxImportRows</c> 条(默认 5000)
  74 + /// </remarks>
  75 + /// <param name="input">批量导入请求体</param>
  76 + /// <returns>成功数、失败数及失败明细(<c>index</c>、<c>userName</c>、<c>message</c>)</returns>
  77 + /// <response code="200">全部或部分行处理完成,见返回体中的计数与 errors</response>
  78 + /// <response code="400">整单校验失败(如 items 为空、超过单次条数上限)</response>
  79 + /// <response code="500">服务器错误</response>
  80 + Task<TeamMemberBatchImportOnlineResultDto> BatchImportOnlineAsync(TeamMemberBatchImportOnlineInputVo input);
42 81 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITrainingAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Training;
  2 +using Volo.Abp.Application.Services;
  3 +
  4 +namespace FoodLabeling.Application.Contracts.IServices;
  5 +
  6 +public interface ITrainingAppService : IApplicationService
  7 +{
  8 + Task<List<TrainingCategoryTreeNodeDto>> GetCategoryTreeAsync(TrainingCategoryTreeInputVo input);
  9 +
  10 + Task<TrainingCategoryGetOutputDto> CreateCategoryAsync(TrainingCategoryCreateInputVo input);
  11 +
  12 + Task<TrainingCategoryGetOutputDto> UpdateCategoryAsync(string id, TrainingCategoryUpdateInputVo input);
  13 +
  14 + Task DeleteCategoryAsync(string id);
  15 +
  16 + Task<TrainingFileDto> UploadFileAsync(TrainingFileUploadInputVo input);
  17 +
  18 + Task<TrainingFileDto> UpdateFileAsync(string id, TrainingFileUpdateInputVo input);
  19 +
  20 + Task DeleteFileAsync(string id);
  21 +
  22 + Task SortFilesAsync(TrainingFileSortInputVo input);
  23 +
  24 + Task<TrainingFileScopeOutputDto> GetFileScopeAsync(string id);
  25 +
  26 + Task<TrainingFileScopeOutputDto> SetFileScopeAsync(string id, TrainingFileScopeInputVo input);
  27 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppTrainingAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Training;
  2 +using Volo.Abp.Application.Services;
  3 +
  4 +namespace FoodLabeling.Application.Contracts.IServices;
  5 +
  6 +/// <summary>
  7 +/// App 培训 / 资料中心
  8 +/// </summary>
  9 +public interface IUsAppTrainingAppService : IApplicationService
  10 +{
  11 + Task<List<TrainingCategoryTreeNodeDto>> GetTreeAsync(UsAppTrainingTreeInputVo input);
  12 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Json/StringOrFirstArrayItemJsonConverter.cs 0 → 100644
  1 +using System.Text.Json;
  2 +using System.Text.Json.Serialization;
  3 +
  4 +namespace FoodLabeling.Application.Contracts.Json;
  5 +
  6 +/// <summary>
  7 +/// 反序列化时兼容 JSON 字符串或字符串数组(取首个非空元素),用于前端误把单选字段传成数组的场景(如 <c>partnerId</c>)。
  8 +/// </summary>
  9 +public sealed class StringOrFirstArrayItemJsonConverter : JsonConverter<string?>
  10 +{
  11 + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
  12 + {
  13 + switch (reader.TokenType)
  14 + {
  15 + case JsonTokenType.Null:
  16 + return null;
  17 + case JsonTokenType.String:
  18 + return reader.GetString();
  19 + case JsonTokenType.StartArray:
  20 + {
  21 + string? first = null;
  22 + while (reader.Read())
  23 + {
  24 + if (reader.TokenType == JsonTokenType.EndArray)
  25 + {
  26 + break;
  27 + }
  28 +
  29 + if (reader.TokenType == JsonTokenType.Null)
  30 + {
  31 + continue;
  32 + }
  33 +
  34 + if (reader.TokenType != JsonTokenType.String)
  35 + {
  36 + throw new JsonException("partnerId 数组元素必须是字符串");
  37 + }
  38 +
  39 + var item = reader.GetString()?.Trim();
  40 + if (first is null && !string.IsNullOrWhiteSpace(item))
  41 + {
  42 + first = item;
  43 + }
  44 + }
  45 +
  46 + return first;
  47 + }
  48 + default:
  49 + throw new JsonException($"无法将 JSON token {reader.TokenType} 转为字符串(partnerId)");
  50 + }
  51 + }
  52 +
  53 + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
  54 + {
  55 + if (value is null)
  56 + {
  57 + writer.WriteNullValue();
  58 + }
  59 + else
  60 + {
  61 + writer.WriteStringValue(value);
  62 + }
  63 + }
  64 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/UsAppJwtClaims.cs
... ... @@ -10,4 +10,7 @@ public static class UsAppJwtClaims
10 10  
11 11 /// <summary>美国版移动端 App</summary>
12 12 public const string ClientKindUsApp = "us-app";
  13 +
  14 + /// <summary>泰额版 App(us-app-auth 转发 th-app-auth 签发)</summary>
  15 + public const string ClientKindThApp = "th_app";
13 16 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj
... ... @@ -4,10 +4,35 @@
4 4 <ItemGroup>
5 5 <PackageReference Include="ClosedXML" Version="0.102.3" />
6 6 <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" />
  7 + <PackageReference Include="MySqlConnector" Version="2.4.0" />
7 8 <PackageReference Include="QuestPDF" Version="2024.12.2" />
8 9 </ItemGroup>
9 10  
10 11 <ItemGroup>
  12 + <EmbeddedResource Include="..\scripts\fl_entity_applied_region_type.sql">
  13 + <LogicalName>FoodLabeling.TenantMigrations.fl_entity_applied_region_type.sql</LogicalName>
  14 + </EmbeddedResource>
  15 + <EmbeddedResource Include="..\scripts\fl_label_partner_id.sql">
  16 + <LogicalName>FoodLabeling.TenantMigrations.fl_label_partner_id.sql</LogicalName>
  17 + </EmbeddedResource>
  18 + <EmbeddedResource Include="..\scripts\fl_product_category_partner_scope.sql">
  19 + <LogicalName>FoodLabeling.TenantMigrations.fl_product_category_partner_scope.sql</LogicalName>
  20 + </EmbeddedResource>
  21 + <EmbeddedResource Include="..\scripts\fl_userlocation.sql">
  22 + <LogicalName>FoodLabeling.TenantMigrations.fl_userlocation.sql</LogicalName>
  23 + </EmbeddedResource>
  24 + <EmbeddedResource Include="..\scripts\fl_team_member_scope.sql">
  25 + <LogicalName>FoodLabeling.TenantMigrations.fl_team_member_scope.sql</LogicalName>
  26 + </EmbeddedResource>
  27 + <EmbeddedResource Include="..\scripts\fl_training.sql">
  28 + <LogicalName>FoodLabeling.TenantMigrations.fl_training.sql</LogicalName>
  29 + </EmbeddedResource>
  30 + <EmbeddedResource Include="..\scripts\fl_label_alert_timer.sql">
  31 + <LogicalName>FoodLabeling.TenantMigrations.fl_label_alert_timer.sql</LogicalName>
  32 + </EmbeddedResource>
  33 + </ItemGroup>
  34 +
  35 + <ItemGroup>
11 36 <ProjectReference Include="..\..\..\framework\Yi.Framework.Ddd.Application\Yi.Framework.Ddd.Application.csproj" />
12 37 <ProjectReference Include="..\..\rbac\Yi.Framework.Rbac.Application.Contracts\Yi.Framework.Rbac.Application.Contracts.csproj" />
13 38 <ProjectReference Include="..\FoodLabeling.Application.Contracts\FoodLabeling.Application.Contracts.csproj" />
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs
... ... @@ -23,22 +23,36 @@ public static class AllScopeBindingHelper
23 23 return selected.Count == 0;
24 24 }
25 25  
26   - if (selected.Count != universe.Count)
  26 + var universeSet = new HashSet<string>(
  27 + universe.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()),
  28 + StringComparer.OrdinalIgnoreCase);
  29 + var selectedSet = new HashSet<string>(
  30 + selected.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()),
  31 + StringComparer.OrdinalIgnoreCase);
  32 +
  33 + if (selectedSet.Count != universeSet.Count)
27 34 {
28 35 return false;
29 36 }
30 37  
31   - var set = new HashSet<string>(universe, StringComparer.Ordinal);
32   - return selected.All(id => set.Contains(id));
  38 + return selectedSet.SetEquals(universeSet);
33 39 }
34 40  
35   - /// <summary>解析维度类型:显式 ALL、空数组 ALL、或 Id 列表全选时视为 ALL。</summary>
  41 + /// <summary>
  42 + /// 解析维度类型:显式 ALL、空数组 ALL、或 Id 列表全选时视为 ALL。
  43 + /// 入参显式 <see cref="ScopeSpecified"/> 时保留 SPECIFIED 并落库全部 Id,不因「当前上下文全选」折叠为 ALL。
  44 + /// </summary>
36 45 public static string ResolveDimensionType(
37 46 string? declaredType,
38 47 IReadOnlyList<string> ids,
39 48 bool hasArrayInPayload,
40 49 bool isFullSelection)
41 50 {
  51 + if (IsDeclaredSpecified(declaredType))
  52 + {
  53 + return ScopeSpecified;
  54 + }
  55 +
42 56 if (isFullSelection || (ids.Count == 0 && IsDeclaredAll(declaredType)))
43 57 {
44 58 return ScopeAll;
... ... @@ -61,6 +75,9 @@ public static class AllScopeBindingHelper
61 75 public static bool IsDeclaredAll(string? type) =>
62 76 string.Equals((type ?? ScopeAll).Trim(), ScopeAll, StringComparison.OrdinalIgnoreCase);
63 77  
  78 + public static bool IsDeclaredSpecified(string? type) =>
  79 + string.Equals((type ?? string.Empty).Trim(), ScopeSpecified, StringComparison.OrdinalIgnoreCase);
  80 +
64 81 /// <summary>全部 Company(<c>fl_partner.Id</c>)。</summary>
65 82 public static async Task<List<string>> ResolveAllPartnerIdsAsync(ISqlSugarClient db)
66 83 {
... ... @@ -87,20 +104,51 @@ public static class AllScopeBindingHelper
87 104 return LocationScopeBindingHelper.NormalizeIds(rows);
88 105 }
89 106  
90   - /// <summary>全部门店;可按 Company / Region 限定。</summary>
  107 + /// <summary>
  108 + /// 全部门店;可按 Company / Region 限定。
  109 + /// 同时传 Company 与 Region 时取<strong>交集</strong>(该 Region 下且属于这些 Company 的门店),
  110 + /// 禁止并集——否则回显「区内全选」无法折叠为 <c>locationIds:["ALL"]</c>。
  111 + /// </summary>
91 112 public static async Task<List<string>> ResolveAllLocationIdsAsync(
92 113 ISqlSugarClient db,
93 114 IReadOnlyList<string>? partnerIds,
94 115 IReadOnlyList<string>? regionIds)
95 116 {
  117 + var partners = LocationScopeBindingHelper.NormalizeIds(partnerIds);
  118 + var regions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds);
  119 +
  120 + if (regions.Count > 0)
  121 + {
  122 + var fromRegions = await LocationScopeBindingHelper.ResolveLocationIdsFromGroupIdsAsync(db, regions);
  123 + if (partners.Count == 0)
  124 + {
  125 + return LocationScopeBindingHelper.NormalizeIds(fromRegions);
  126 + }
  127 +
  128 + var partnerLocSet = new HashSet<string>(
  129 + await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners),
  130 + StringComparer.OrdinalIgnoreCase);
  131 + return LocationScopeBindingHelper.NormalizeIds(
  132 + fromRegions.Where(id => partnerLocSet.Contains(id)).ToList());
  133 + }
  134 +
  135 + if (partners.Count > 0)
  136 + {
  137 + return await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners);
  138 + }
  139 +
96 140 var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
97 141 db,
98   - partnerIds,
99   - regionIds,
  142 + (IReadOnlyList<string>?)null,
  143 + null,
100 144 null);
101 145 return LocationScopeBindingHelper.NormalizeIds(merged);
102 146 }
103 147  
  148 + /// <summary>Id 数组是否含 <c>ALL</c> 哨兵(大小写不敏感;与具体 Guid 同传时以 ALL 为准)。</summary>
  149 + public static bool HasAllScopeSentinelSelection(IReadOnlyList<string>? ids) =>
  150 + LocationScopeBindingHelper.ContainsAllScopeSentinel(ids);
  151 +
104 152 /// <summary>Company 维度是否应存为 ALL(含「勾选了全部 Company」)。</summary>
105 153 public static async Task<(string Type, List<string> Ids)> NormalizePartnerScopeAsync(
106 154 ISqlSugarClient db,
... ... @@ -110,6 +158,10 @@ public static class AllScopeBindingHelper
110 158 bool hasArrayInPayload)
111 159 {
112 160 var ids = LabelEntityPartnerScopeHelper.NormalizePartnerIds(partnerIds, companyIds);
  161 + if (HasAllScopeSentinelSelection(ids))
  162 + {
  163 + return (ScopeAll, new List<string>());
  164 + }
113 165 var allPartners = await ResolveAllPartnerIdsAsync(db);
114 166 // 勾选当前全部 Company(含前端仍传 SPECIFIED + 全量 Id)→ 归档 ALL,后续新增 Company 动态适用
115 167 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allPartners);
... ... @@ -125,7 +177,12 @@ public static class AllScopeBindingHelper
125 177 bool hasArrayInPayload,
126 178 IReadOnlyList<string>? partnerIdsForContext)
127 179 {
128   - var ids = LocationScopeBindingHelper.NormalizeIds(regionIds);
  180 + if (HasAllScopeSentinelSelection(regionIds))
  181 + {
  182 + return (ScopeAll, new List<string>());
  183 + }
  184 +
  185 + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds);
129 186 var allRegions = await ResolveAllRegionIdsAsync(db, partnerIdsForContext);
130 187 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allRegions);
131 188 var type = ResolveDimensionType(declaredType, ids, hasArrayInPayload, isFull);
... ... @@ -141,7 +198,12 @@ public static class AllScopeBindingHelper
141 198 IReadOnlyList<string>? partnerIdsForContext,
142 199 IReadOnlyList<string>? regionIdsForContext)
143 200 {
144   - var ids = LocationScopeBindingHelper.NormalizeIds(locationIds);
  201 + if (HasAllScopeSentinelSelection(locationIds))
  202 + {
  203 + return (ScopeAll, new List<string>());
  204 + }
  205 +
  206 + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds);
145 207 var allLocations = await ResolveAllLocationIdsAsync(db, partnerIdsForContext, regionIdsForContext);
146 208 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allLocations);
147 209 var type = ResolveDimensionType(declaredType, ids, hasArrayInPayload, isFull);
... ... @@ -162,7 +224,7 @@ public static class AllScopeBindingHelper
162 224 return new List<string> { partnerId.Trim() };
163 225 }
164 226  
165   - var regionIds = LocationScopeBindingHelper.NormalizeIds(regionOrGroupIds);
  227 + var regionIds = LocationScopeBindingHelper.FilterConcreteScopeIds(regionOrGroupIds);
166 228 if (regionIds.Count > 0)
167 229 {
168 230 var rows = await db.Queryable<FlGroupDbEntity>()
... ... @@ -173,7 +235,7 @@ public static class AllScopeBindingHelper
173 235 return normalized.Count > 0 ? normalized : null;
174 236 }
175 237  
176   - var locIds = LocationScopeBindingHelper.NormalizeIds(locationIds);
  238 + var locIds = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds);
177 239 if (locIds.Count == 0)
178 240 {
179 241 return null;
... ... @@ -195,27 +257,55 @@ public static class AllScopeBindingHelper
195 257 bool hasScopeArrays,
196 258 IReadOnlyList<string>? partnerIdsForContext)
197 259 {
  260 + var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds);
  261 + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds);
  262 +
  263 + // 具体 Region(非全局 ALL)下即使覆盖该区全部门店,也只存 SPECIFIED 快照,不升全局 AvailabilityType=ALL
  264 + if (concreteRegions.Count > 0)
  265 + {
  266 + return false;
  267 + }
  268 +
  269 + // locationIds 含 ALL 且无具体 Region:由调用方按 Company 展开或归档全局 ALL
  270 + if (HasAllScopeSentinelSelection(locationIds))
  271 + {
  272 + return true;
  273 + }
  274 +
  275 + if (HasAllScopeSentinelSelection(regionIds) && concreteLocations.Count == 0)
  276 + {
  277 + return true;
  278 + }
  279 +
198 280 if (IsDeclaredAll(declaredAvailabilityType)
199   - && LocationScopeBindingHelper.NormalizeIds(regionIds).Count == 0
200   - && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0)
  281 + && concreteRegions.Count == 0
  282 + && concreteLocations.Count == 0)
201 283 {
202 284 return true;
203 285 }
204 286  
205 287 if (hasScopeArrays
206   - && LocationScopeBindingHelper.NormalizeIds(regionIds).Count == 0
207   - && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0
  288 + && concreteRegions.Count == 0
  289 + && concreteLocations.Count == 0
208 290 && IsDeclaredAll(declaredAvailabilityType))
209 291 {
210 292 return true;
211 293 }
212 294  
213   - // 前端 Select All 常传 SPECIFIED + 当前全量 Id:覆盖 Company 上下文全部门店时归档 ALL
214   - var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
  295 + // 前端 Select All 常传 SPECIFIED + 当前全量 Id:覆盖上下文全部门店时归档 ALL。
  296 + // 有具体 locationIds 时:只按这些门店落 SPECIFIED 快照,禁止再升成 AvailabilityType=ALL
  297 + // (否则「Region=ALL + 选 1 店」在公司仅 1 店或误判全选时会清空快照并回显成全局 ALL)。
  298 + if (concreteLocations.Count > 0)
  299 + {
  300 + return false;
  301 + }
  302 +
  303 + List<string> merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
215 304 db,
216 305 (IReadOnlyList<string>?)null,
217   - regionIds,
218   - locationIds);
  306 + concreteRegions,
  307 + concreteLocations);
  308 +
219 309 if (merged.Count == 0)
220 310 {
221 311 return false;
... ... @@ -224,14 +314,14 @@ public static class AllScopeBindingHelper
224 314 var partnerContext = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext);
225 315 if (partnerContext.Count == 0)
226 316 {
227   - partnerContext = await ResolvePartnerContextFromScopeAsync(db, null, regionIds, locationIds)
  317 + partnerContext = await ResolvePartnerContextFromScopeAsync(db, null, concreteRegions, concreteLocations)
228 318 ?? new List<string>();
229 319 }
230 320  
231 321 var allLocations = await ResolveAllLocationIdsAsync(
232 322 db,
233 323 partnerContext.Count > 0 ? partnerContext : null,
234   - null);
  324 + concreteRegions.Count > 0 ? concreteRegions : null);
235 325 return IsFullIdSelection(merged, allLocations);
236 326 }
237 327  
... ... @@ -244,10 +334,35 @@ public static class AllScopeBindingHelper
244 334 IReadOnlyList<string>? locationIds,
245 335 bool hasScopePayload)
246 336 {
  337 + // partnerId 字符串为 ALL 哨兵:全选 Company,归档 ALL(禁止把 ALL 当 Guid 查库)
  338 + if (LocationScopeBindingHelper.IsAllScopeSentinel(partnerId))
  339 + {
  340 + return true;
  341 + }
  342 +
  343 + var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds);
  344 + var concreteGroups = LocationScopeBindingHelper.FilterConcreteScopeIds(groupIds);
  345 +
  346 + // 具体 Region 下不升全局 AvailabilityType=ALL(与 ShouldTreatMergedLocationScopeAsAllAsync 一致)
  347 + if (concreteGroups.Count > 0)
  348 + {
  349 + return false;
  350 + }
  351 +
  352 + if (HasAllScopeSentinelSelection(locationIds))
  353 + {
  354 + return true;
  355 + }
  356 +
  357 + if (HasAllScopeSentinelSelection(groupIds) && concreteLocations.Count == 0)
  358 + {
  359 + return true;
  360 + }
  361 +
247 362 if (IsDeclaredAll(declaredAvailabilityType)
248 363 && string.IsNullOrWhiteSpace(partnerId)
249   - && LocationScopeBindingHelper.NormalizeIds(groupIds).Count == 0
250   - && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0)
  364 + && concreteGroups.Count == 0
  365 + && concreteLocations.Count == 0)
251 366 {
252 367 return true;
253 368 }
... ... @@ -257,20 +372,29 @@ public static class AllScopeBindingHelper
257 372 return false;
258 373 }
259 374  
  375 + // 有具体门店时不升全局 ALL
  376 + if (concreteLocations.Count > 0)
  377 + {
  378 + return false;
  379 + }
  380 +
260 381 var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
261 382 db,
262 383 partnerId,
263   - groupIds,
264   - locationIds);
  384 + concreteGroups,
  385 + concreteLocations);
  386 +
265 387 if (merged.Count == 0)
266 388 {
267 389 return false;
268 390 }
269 391  
270   - // 未传 partnerId 时从 Region/门店反推 Company,避免用「全系统门店」误判导致无法归档 ALL
271 392 var partnerContext = await ResolvePartnerContextFromScopeAsync(
272   - db, partnerId, groupIds, locationIds);
273   - var allLocations = await ResolveAllLocationIdsAsync(db, partnerContext, null);
  393 + db, partnerId, concreteGroups, concreteLocations);
  394 + var allLocations = await ResolveAllLocationIdsAsync(
  395 + db,
  396 + partnerContext,
  397 + concreteGroups.Count > 0 ? concreteGroups : null);
274 398 return IsFullIdSelection(merged, allLocations);
275 399 }
276 400  
... ... @@ -302,4 +426,169 @@ public static class AllScopeBindingHelper
302 426 : await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, locs);
303 427 return (partnerIds, regionIds, locs);
304 428 }
  429 +
  430 + /// <summary>
  431 + /// 标签/产品分类等:解析 Region + Location 落库(对齐 Label:Region=ALL 时可保留具体门店快照)。
  432 + /// </summary>
  433 + public sealed class LabelEntityRegionLocationSaveResult
  434 + {
  435 + public string AvailabilityType { get; init; } = ScopeSpecified;
  436 +
  437 + public string AppliedRegionType { get; init; } = ScopeAll;
  438 +
  439 + public List<string> LocationIds { get; init; } = new();
  440 + }
  441 +
  442 + public static async Task<LabelEntityRegionLocationSaveResult> ResolveLabelEntityRegionLocationForSaveAsync(
  443 + ISqlSugarClient db,
  444 + string? declaredAvailabilityType,
  445 + IReadOnlyList<string>? partnerIdsForContext,
  446 + IReadOnlyList<string>? mergedRegionIdsRaw,
  447 + IReadOnlyList<string>? locationIdsRaw,
  448 + bool hasScopeArrays)
  449 + {
  450 + var normalizedLocationIds = LocationScopeBindingHelper.NormalizeIds(locationIdsRaw);
  451 + var mergedRegionIds = LocationScopeBindingHelper.NormalizeIds(mergedRegionIdsRaw);
  452 + var regionIds = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedRegionIds);
  453 + var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(normalizedLocationIds);
  454 + var locationHasAll = HasAllScopeSentinelSelection(normalizedLocationIds);
  455 + var regionHasAllSentinel = HasAllScopeSentinelSelection(mergedRegionIds);
  456 + var hasConcretePartner = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext).Count > 0;
  457 + // 用户显式传具体门店 Id 时一律 SPECIFIED,不因「恰好覆盖 Region 全集」归档 ALL
  458 + var userExplicitLocations = explicitLocationIds.Count > 0 && !locationHasAll;
  459 +
  460 + // 前端 Select All Region 常展开为全量 Guid,需识别为 Region=ALL
  461 + var regionHasAll = regionHasAllSentinel;
  462 + if (!regionHasAll && regionIds.Count > 0)
  463 + {
  464 + var allRegions = await ResolveAllRegionIdsAsync(
  465 + db,
  466 + hasConcretePartner ? partnerIdsForContext : null);
  467 + regionHasAll = allRegions.Count > 0 && IsFullIdSelection(regionIds, allRegions);
  468 + }
  469 +
  470 + // location ALL 哨兵,或具体 Region 下「空选/全选门店」:有 Company/Region 上下文则展开 SPECIFIED
  471 + var locationCoversConcreteRegions = false;
  472 + if (!locationHasAll && !userExplicitLocations && regionIds.Count > 0 && !regionHasAll)
  473 + {
  474 + if (explicitLocationIds.Count == 0)
  475 + {
  476 + locationCoversConcreteRegions = true;
  477 + }
  478 + else
  479 + {
  480 + var regionUniverse = await ResolveAllLocationIdsAsync(
  481 + db,
  482 + hasConcretePartner ? partnerIdsForContext : null,
  483 + regionIds);
  484 + locationCoversConcreteRegions = regionUniverse.Count > 0
  485 + && IsFullIdSelection(explicitLocationIds, regionUniverse);
  486 + }
  487 + }
  488 +
  489 + if (!userExplicitLocations && (locationHasAll || locationCoversConcreteRegions))
  490 + {
  491 + var expandRegions = regionHasAll ? null : (regionIds.Count > 0 ? regionIds : null);
  492 + var expanded = await LocationScopeBindingHelper.ExpandScopedAllLocationsForSaveAsync(
  493 + db,
  494 + hasConcretePartner ? partnerIdsForContext : null,
  495 + expandRegions);
  496 + if (expanded is not null)
  497 + {
  498 + return new LabelEntityRegionLocationSaveResult
  499 + {
  500 + AvailabilityType = ScopeSpecified,
  501 + AppliedRegionType = regionHasAll || regionIds.Count == 0 ? ScopeAll : ScopeSpecified,
  502 + LocationIds = expanded
  503 + };
  504 + }
  505 +
  506 + return new LabelEntityRegionLocationSaveResult
  507 + {
  508 + AvailabilityType = ScopeAll,
  509 + AppliedRegionType = ScopeAll,
  510 + LocationIds = new List<string>()
  511 + };
  512 + }
  513 +
  514 + // Region=ALL + 具体门店:Region 存 ALL,门店 SPECIFIED 快照(可回显 regionIds=["ALL"] + 单个 locationId)
  515 + if (regionHasAll && explicitLocationIds.Count > 0)
  516 + {
  517 + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds);
  518 + return new LabelEntityRegionLocationSaveResult
  519 + {
  520 + AvailabilityType = ScopeSpecified,
  521 + AppliedRegionType = ScopeAll,
  522 + LocationIds = explicitLocationIds
  523 + };
  524 + }
  525 +
  526 + // Region=ALL 且无具体门店 → 双 ALL
  527 + if (regionHasAll)
  528 + {
  529 + return new LabelEntityRegionLocationSaveResult
  530 + {
  531 + AvailabilityType = ScopeAll,
  532 + AppliedRegionType = ScopeAll,
  533 + LocationIds = new List<string>()
  534 + };
  535 + }
  536 +
  537 + // 有具体门店(可同传具体 Region):以门店为准
  538 + if (explicitLocationIds.Count > 0)
  539 + {
  540 + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds);
  541 + return new LabelEntityRegionLocationSaveResult
  542 + {
  543 + AvailabilityType = ScopeSpecified,
  544 + AppliedRegionType = ScopeSpecified,
  545 + LocationIds = explicitLocationIds
  546 + };
  547 + }
  548 +
  549 + if (await ShouldTreatMergedLocationScopeAsAllAsync(
  550 + db,
  551 + declaredAvailabilityType,
  552 + regionIds,
  553 + explicitLocationIds,
  554 + hasScopeArrays,
  555 + partnerIdsForContext))
  556 + {
  557 + return new LabelEntityRegionLocationSaveResult
  558 + {
  559 + AvailabilityType = ScopeAll,
  560 + AppliedRegionType = ScopeAll,
  561 + LocationIds = new List<string>()
  562 + };
  563 + }
  564 +
  565 + var availabilityType = (declaredAvailabilityType ?? ScopeAll).Trim().ToUpperInvariant();
  566 + if (regionIds.Count > 0)
  567 + {
  568 + availabilityType = ScopeSpecified;
  569 + }
  570 + else if (hasScopeArrays && IsDeclaredAll(availabilityType))
  571 + {
  572 + availabilityType = ScopeAll;
  573 + }
  574 +
  575 + if (availabilityType != ScopeAll && availabilityType != ScopeSpecified)
  576 + {
  577 + throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)");
  578 + }
  579 +
  580 + var locationSpecified = string.Equals(availabilityType, ScopeSpecified, StringComparison.OrdinalIgnoreCase);
  581 + var savedLocationIds = await LocationScopeBindingHelper.ResolveEntityLocationIdsForSaveAsync(
  582 + db,
  583 + locationSpecified,
  584 + regionIds,
  585 + explicitLocationIds);
  586 +
  587 + return new LabelEntityRegionLocationSaveResult
  588 + {
  589 + AvailabilityType = locationSpecified ? ScopeSpecified : ScopeAll,
  590 + AppliedRegionType = locationSpecified ? ScopeSpecified : ScopeAll,
  591 + LocationIds = savedLocationIds
  592 + };
  593 + }
305 594 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/EntityLocationScopeDisplayHelper.cs
... ... @@ -11,6 +11,10 @@ public static class EntityLocationScopeDisplayHelper
11 11 /// <summary>
12 12 /// 根据可用范围类型与已解析的 Region/Location Id、名称,生成列表展示文案。
13 13 /// </summary>
  14 + /// <remarks>
  15 + /// 有具体门店 Guid 时始终展示门店名称,不因「覆盖某 Region 下全部门店」误显示 All Location
  16 + /// (常见于 <c>AppliedRegionType=ALL</c> + 单个 <c>locationIds</c>)。
  17 + /// </remarks>
14 18 public static async Task<(string Region, string Location)> BuildListDisplayAsync(
15 19 ISqlSugarClient db,
16 20 string? availabilityType,
... ... @@ -18,7 +22,8 @@ public static class EntityLocationScopeDisplayHelper
18 22 IReadOnlyList<string> locationIds,
19 23 IEnumerable<string> regionNames,
20 24 IEnumerable<string> locationNames,
21   - IReadOnlyList<string>? partnerIdsForContext)
  25 + IReadOnlyList<string>? partnerIdsForContext,
  26 + string? appliedRegionType = null)
22 27 {
23 28 if (AllScopeBindingHelper.IsDeclaredAll(availabilityType))
24 29 {
... ... @@ -29,27 +34,33 @@ public static class EntityLocationScopeDisplayHelper
29 34 var normLocationIds = LocationScopeBindingHelper.NormalizeIds(locationIds);
30 35 var partnerContext = partnerIdsForContext is { Count: > 0 } ? partnerIdsForContext : null;
31 36  
32   - var allRegionIds = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, partnerContext);
33   - var regionIsAll = normRegionIds.Count > 0
34   - && allRegionIds.Count > 0
35   - && AllScopeBindingHelper.IsFullIdSelection(normRegionIds, allRegionIds);
36   -
37   - var regionContextForLocations = regionIsAll ? null : normRegionIds.Count > 0 ? normRegionIds : null;
38   - var allLocationIds = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(
39   - db,
40   - partnerContext,
41   - regionContextForLocations);
42   - var locationIsAll = normLocationIds.Count > 0
43   - && allLocationIds.Count > 0
44   - && AllScopeBindingHelper.IsFullIdSelection(normLocationIds, allLocationIds);
45   -
46   - var regionDisplay = regionIsAll
47   - ? AllScopeBindingHelper.AllRegionsDisplay
48   - : JoinDistinctNames(regionNames);
  37 + string regionDisplay;
  38 + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType)
  39 + || AllScopeBindingHelper.HasAllScopeSentinelSelection(normRegionIds))
  40 + {
  41 + regionDisplay = AllScopeBindingHelper.AllRegionsDisplay;
  42 + }
  43 + else
  44 + {
  45 + var allRegionIds = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, partnerContext);
  46 + var regionIsAll = normRegionIds.Count > 0
  47 + && allRegionIds.Count > 0
  48 + && AllScopeBindingHelper.IsFullIdSelection(normRegionIds, allRegionIds);
  49 + regionDisplay = regionIsAll
  50 + ? AllScopeBindingHelper.AllRegionsDisplay
  51 + : JoinDistinctNames(regionNames);
  52 + }
49 53  
50   - var locationDisplay = locationIsAll
51   - ? AllScopeBindingHelper.AllLocationsDisplay
52   - : JoinDistinctNames(locationNames);
  54 + // 具体门店 Guid → 显示门店名;仅 AvailabilityType=ALL 或 locationIds 含 ALL 哨兵才显示 All Location
  55 + string locationDisplay;
  56 + if (AllScopeBindingHelper.HasAllScopeSentinelSelection(normLocationIds))
  57 + {
  58 + locationDisplay = AllScopeBindingHelper.AllLocationsDisplay;
  59 + }
  60 + else
  61 + {
  62 + locationDisplay = JoinDistinctNames(locationNames);
  63 + }
53 64  
54 65 return (regionDisplay, locationDisplay);
55 66 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelAlertTimerWriteHelper.cs 0 → 100644
  1 +using System.Globalization;
  2 +using FoodLabeling.Application.Services.DbModels;
  3 +using FoodLabeling.Domain.Shared.Helpers;
  4 +using SqlSugar;
  5 +
  6 +namespace FoodLabeling.Application.Helpers;
  7 +
  8 +/// <summary>
  9 +/// 打印成功后写入告警计时器(按 BatchId 幂等,一条批次一条计时器)。
  10 +/// </summary>
  11 +public static class LabelAlertTimerWriteHelper
  12 +{
  13 + /// <summary>
  14 + /// 根据打印批次创建告警计时器;已存在未删除记录或无有效过期时刻时直接返回。
  15 + /// </summary>
  16 + public static async Task TryCreateFromPrintBatchAsync(
  17 + ISqlSugarClient db,
  18 + string batchId,
  19 + string? createdBy,
  20 + CancellationToken ct = default)
  21 + {
  22 + var bid = batchId?.Trim();
  23 + if (string.IsNullOrWhiteSpace(bid))
  24 + {
  25 + return;
  26 + }
  27 +
  28 + ct.ThrowIfCancellationRequested();
  29 +
  30 + // BatchId 有唯一索引:含软删记录也不再插入,避免幂等补写撞唯一约束
  31 + var exists = await db.Queryable<FlLabelAlertTimerDbEntity>()
  32 + .AnyAsync(x => x.BatchId == bid);
  33 + if (exists)
  34 + {
  35 + return;
  36 + }
  37 +
  38 + var task = (await db.Queryable<FlLabelPrintTaskDbEntity>()
  39 + .Where(x => x.BatchId == bid)
  40 + .OrderBy(x => x.CopyIndex)
  41 + .Take(1)
  42 + .ToListAsync(ct))
  43 + .FirstOrDefault();
  44 + if (task is null)
  45 + {
  46 + return;
  47 + }
  48 +
  49 + var printedAt = task.PrintedAt ?? task.BaseTime ?? task.CreationTime;
  50 + if (!ReportsPrintLogExpiryHelper.TryResolveExpiryDateTime(
  51 + task.PrintInputJson,
  52 + task.RenderTemplateJson,
  53 + task.BaseTime,
  54 + printedAt,
  55 + out var expiresAt))
  56 + {
  57 + return;
  58 + }
  59 +
  60 + var label = (await db.Queryable<FlLabelDbEntity>()
  61 + .Where(x => x.Id == task.LabelId)
  62 + .Select(x => new { x.LabelName, x.LabelCode })
  63 + .Take(1)
  64 + .ToListAsync(ct))
  65 + .FirstOrDefault();
  66 +
  67 + var labelName = label?.LabelName?.Trim();
  68 + if (string.IsNullOrWhiteSpace(labelName))
  69 + {
  70 + labelName = FoodLabelingDisplayConsts.NotAvailable;
  71 + }
  72 +
  73 + string? productName = null;
  74 + if (!string.IsNullOrWhiteSpace(task.ProductId))
  75 + {
  76 + productName = (await db.Queryable<FlProductDbEntity>()
  77 + .Where(x => x.Id == task.ProductId && !x.IsDeleted)
  78 + .Select(x => x.ProductName)
  79 + .Take(1)
  80 + .ToListAsync(ct))
  81 + .FirstOrDefault();
  82 + }
  83 +
  84 + var durationSeconds = Math.Max(0, (int)(expiresAt - printedAt).TotalSeconds);
  85 + var title = BuildTitle(labelName, durationSeconds);
  86 + var subtitle = BuildSubtitle(durationSeconds, expiresAt);
  87 + var now = DateTime.Now;
  88 +
  89 + var entity = new FlLabelAlertTimerDbEntity
  90 + {
  91 + Id = YitIdHelper.NextId().ToString(),
  92 + BatchId = bid,
  93 + PrintTaskId = task.Id,
  94 + LabelId = task.LabelId,
  95 + LabelCode = label?.LabelCode?.Trim(),
  96 + LabelName = labelName,
  97 + ProductId = task.ProductId,
  98 + ProductName = string.IsNullOrWhiteSpace(productName) ? null : productName.Trim(),
  99 + LocationId = task.LocationId?.Trim() ?? string.Empty,
  100 + PrintedAt = printedAt,
  101 + BaseTime = task.BaseTime,
  102 + ExpiresAt = expiresAt,
  103 + DurationSeconds = durationSeconds,
  104 + Title = title,
  105 + Subtitle = subtitle,
  106 + IsDeleted = false,
  107 + DeletionTime = null,
  108 + CreatedBy = createdBy,
  109 + CreationTime = now
  110 + };
  111 +
  112 + await db.Insertable(entity).ExecuteCommandAsync();
  113 + }
  114 +
  115 + private static string BuildTitle(string labelName, int durationSeconds)
  116 + {
  117 + var hoursText = FormatDurationHoursLabel(durationSeconds);
  118 + return string.IsNullOrWhiteSpace(hoursText) ? labelName : $"{labelName} ({hoursText})";
  119 + }
  120 +
  121 + private static string BuildSubtitle(int durationSeconds, DateTime expiresAt)
  122 + {
  123 + var hoursText = FormatDurationHoursLabel(durationSeconds);
  124 + var timeText = expiresAt.ToString("h:mm tt", CultureInfo.CurrentCulture);
  125 + return string.IsNullOrWhiteSpace(hoursText)
  126 + ? $"Completes at {timeText}"
  127 + : $"{hoursText} Completes at {timeText}";
  128 + }
  129 +
  130 + private static string FormatDurationHoursLabel(int durationSeconds)
  131 + {
  132 + if (durationSeconds <= 0)
  133 + {
  134 + return string.Empty;
  135 + }
  136 +
  137 + var totalHours = durationSeconds / 3600.0;
  138 + if (totalHours >= 1)
  139 + {
  140 + var rounded = (int)Math.Round(totalHours, MidpointRounding.AwayFromZero);
  141 + rounded = Math.Max(1, rounded);
  142 + return rounded == 1 ? "1 hour" : $"{rounded} hours";
  143 + }
  144 +
  145 + var minutes = Math.Max(1, (int)Math.Ceiling(durationSeconds / 60.0));
  146 + return minutes == 1 ? "1 minute" : $"{minutes} minutes";
  147 + }
  148 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityListScopeHelper.cs
... ... @@ -4,12 +4,37 @@ using SqlSugar;
4 4 namespace FoodLabeling.Application.Helpers;
5 5  
6 6 /// <summary>
7   -/// 标签类型/分类/多选项列表:非平台管理员仅可见其绑定 Company 范围内数据。
  7 +/// 标签类型/分类/多选项列表:按可见门店范围过滤。
  8 +/// AvailabilityType=ALL 时传任意 Region/Location 筛选均命中。
8 9 /// </summary>
9 10 public static class LabelEntityListScopeHelper
10 11 {
11 12 /// <summary>
12   - /// 标签类型列表:按 Company + Region/Location 范围过滤(Company Admin 不可见 All Companies 数据)。
  13 + /// 是否应用门店 Availability 过滤。
  14 + /// 仅传 <c>partnerId</c>(未传 groupId/locationId)时返回 false:只按 Company 维度筛,
  15 + /// 避免「多公司绑定」在第二家公司暂无门店或门店未落入关联表时被误杀。
  16 + /// </summary>
  17 + public static bool ShouldApplyLocationAvailabilityFilter(
  18 + string? partnerId,
  19 + string? groupId,
  20 + string? locationId)
  21 + {
  22 + if (!string.IsNullOrWhiteSpace(groupId) || !string.IsNullOrWhiteSpace(locationId))
  23 + {
  24 + return true;
  25 + }
  26 +
  27 + // 仅 PartnerId:Company 过滤由 Apply*PartnerListFilter 负责
  28 + if (!string.IsNullOrWhiteSpace(partnerId))
  29 + {
  30 + return false;
  31 + }
  32 +
  33 + return true;
  34 + }
  35 +
  36 + /// <summary>
  37 + /// 标签类型列表:门店可用范围过滤(ALL 命中任意门店筛选)。
13 38 /// </summary>
14 39 public static ISugarQueryable<FlLabelTypeDbEntity> ApplyTypeLocationAvailabilityFilter(
15 40 ISugarQueryable<FlLabelTypeDbEntity> query,
... ... @@ -22,12 +47,12 @@ public static class LabelEntityListScopeHelper
22 47  
23 48 if (scopedLocationIds.Count == 0)
24 49 {
25   - return query.Where(_ => false);
  50 + // 无可见门店时仍保留 AvailabilityType=ALL(仅 Company 筛选时公司可能暂无门店)
  51 + return query.Where(t => t.AvailabilityType == AllScopeBindingHelper.ScopeAll);
26 52 }
27 53  
28 54 return query.Where(t =>
29   - (t.AvailabilityType == AllScopeBindingHelper.ScopeAll
30   - && t.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified)
  55 + t.AvailabilityType == AllScopeBindingHelper.ScopeAll
31 56 || (t.AvailabilityType == AllScopeBindingHelper.ScopeSpecified
32 57 && SqlFunc.Subqueryable<FlLabelTypeLocationDbEntity>()
33 58 .Where(tl => tl.LabelTypeId == t.Id && scopedLocationIds.Contains(tl.LocationId))
... ... @@ -35,7 +60,7 @@ public static class LabelEntityListScopeHelper
35 60 }
36 61  
37 62 /// <summary>
38   - /// 标签分类列表:按 Company + Region/Location 范围过滤
  63 + /// 标签分类列表:门店可用范围过滤(ALL 命中任意门店筛选)
39 64 /// </summary>
40 65 public static ISugarQueryable<FlLabelCategoryDbEntity> ApplyCategoryLocationAvailabilityFilter(
41 66 ISugarQueryable<FlLabelCategoryDbEntity> query,
... ... @@ -48,12 +73,11 @@ public static class LabelEntityListScopeHelper
48 73  
49 74 if (scopedLocationIds.Count == 0)
50 75 {
51   - return query.Where(_ => false);
  76 + return query.Where(c => c.AvailabilityType == AllScopeBindingHelper.ScopeAll);
52 77 }
53 78  
54 79 return query.Where(c =>
55   - (c.AvailabilityType == AllScopeBindingHelper.ScopeAll
56   - && c.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified)
  80 + c.AvailabilityType == AllScopeBindingHelper.ScopeAll
57 81 || (c.AvailabilityType == AllScopeBindingHelper.ScopeSpecified
58 82 && SqlFunc.Subqueryable<FlLabelCategoryLocationDbEntity>()
59 83 .Where(cl => cl.CategoryId == c.Id && scopedLocationIds.Contains(cl.LocationId))
... ... @@ -61,7 +85,7 @@ public static class LabelEntityListScopeHelper
61 85 }
62 86  
63 87 /// <summary>
64   - /// 标签多选项列表:按 Company + Region/Location 范围过滤
  88 + /// 标签多选项列表:门店可用范围过滤(ALL 命中任意门店筛选)
65 89 /// </summary>
66 90 public static ISugarQueryable<FlLabelMultipleOptionDbEntity> ApplyMultipleOptionLocationAvailabilityFilter(
67 91 ISugarQueryable<FlLabelMultipleOptionDbEntity> query,
... ... @@ -74,12 +98,11 @@ public static class LabelEntityListScopeHelper
74 98  
75 99 if (scopedLocationIds.Count == 0)
76 100 {
77   - return query.Where(_ => false);
  101 + return query.Where(o => o.AvailabilityType == AllScopeBindingHelper.ScopeAll);
78 102 }
79 103  
80 104 return query.Where(o =>
81   - (o.AvailabilityType == AllScopeBindingHelper.ScopeAll
82   - && o.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified)
  105 + o.AvailabilityType == AllScopeBindingHelper.ScopeAll
83 106 || (o.AvailabilityType == AllScopeBindingHelper.ScopeSpecified
84 107 && SqlFunc.Subqueryable<FlLabelMultipleOptionLocationDbEntity>()
85 108 .Where(ol => ol.MultipleOptionId == o.Id && scopedLocationIds.Contains(ol.LocationId))
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelEntityPartnerScopeHelper.cs
... ... @@ -26,7 +26,10 @@ public static class LabelEntityPartnerScopeHelper
26 26 Category,
27 27  
28 28 /// <summary>标签多选项 <c>fl_label_multiple_option</c></summary>
29   - MultipleOption
  29 + MultipleOption,
  30 +
  31 + /// <summary>产品分类 <c>fl_product_category</c></summary>
  32 + ProductCategory
30 33 }
31 34  
32 35 public sealed class LabelEntityPartnerScopeSaveResult
... ... @@ -60,6 +63,10 @@ public static class LabelEntityPartnerScopeHelper
60 63 public bool HasCategoryPartnerTable { get; init; }
61 64  
62 65 public bool HasMultipleOptionPartnerTable { get; init; }
  66 +
  67 + public bool HasProductCategoryPartnerColumn { get; init; }
  68 +
  69 + public bool HasProductCategoryPartnerTable { get; init; }
63 70 }
64 71  
65 72 /// <summary>
... ... @@ -185,7 +192,7 @@ public static class LabelEntityPartnerScopeHelper
185 192 if (needsPartnerRows && !hasTable)
186 193 {
187 194 throw new UserFriendlyException(
188   - "Company 适用范围关联表尚未就绪,请联系管理员执行数据库迁移(fl_label_entity_partner_scope.sql)后重试");
  195 + "Company 适用范围关联表尚未就绪,请联系管理员执行数据库迁移(fl_label_entity_partner_scope.sql 或 fl_product_category_partner_scope.sql)后重试");
189 196 }
190 197  
191 198 if (hasTable)
... ... @@ -325,11 +332,30 @@ public static class LabelEntityPartnerScopeHelper
325 332 ? AllCompaniesDisplay
326 333 : BuildCompanyDisplay(pIds, partnerNameById);
327 334  
  335 + List<string> displayPartnerIds;
  336 + if (string.Equals(partnerType, ScopeAll, StringComparison.OrdinalIgnoreCase))
  337 + {
  338 + displayPartnerIds = new List<string> { AllScopeBindingHelper.ScopeAll };
  339 + }
  340 + else
  341 + {
  342 + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync(
  343 + db,
  344 + pIds,
  345 + null,
  346 + null,
  347 + new ScopeAllEchoHelper.ScopeAllEchoOptions
  348 + {
  349 + AppliedPartnerType = partnerType
  350 + });
  351 + displayPartnerIds = collapsed.PartnerIds;
  352 + }
  353 +
328 354 result[entityId] = new LabelEntityPartnerScopeDisplay
329 355 {
330 356 Company = companyDisplay,
331 357 AppliedPartnerType = partnerType,
332   - PartnerIds = pIds
  358 + PartnerIds = displayPartnerIds
333 359 };
334 360 }
335 361  
... ... @@ -383,11 +409,13 @@ public static class LabelEntityPartnerScopeHelper
383 409 return query.Where(_ => false);
384 410 }
385 411  
  412 + // Company=ALL:传任意 partnerId 均命中;SPECIFIED:须与关联表有交集
386 413 return query.Where(t =>
387   - t.AppliedPartnerType == ScopeSpecified
388   - && SqlFunc.Subqueryable<FlLabelTypePartnerDbEntity>()
389   - .Where(p => p.LabelTypeId == t.Id && scopedPartnerIds.Contains(p.PartnerId))
390   - .Any());
  414 + t.AppliedPartnerType == ScopeAll
  415 + || (t.AppliedPartnerType == ScopeSpecified
  416 + && SqlFunc.Subqueryable<FlLabelTypePartnerDbEntity>()
  417 + .Where(p => p.LabelTypeId == t.Id && scopedPartnerIds.Contains(p.PartnerId))
  418 + .Any()));
391 419 }
392 420  
393 421 /// <summary>
... ... @@ -415,10 +443,11 @@ public static class LabelEntityPartnerScopeHelper
415 443 }
416 444  
417 445 return query.Where(c =>
418   - c.AppliedPartnerType == ScopeSpecified
419   - && SqlFunc.Subqueryable<FlLabelCategoryPartnerDbEntity>()
420   - .Where(p => p.CategoryId == c.Id && scopedPartnerIds.Contains(p.PartnerId))
421   - .Any());
  446 + c.AppliedPartnerType == ScopeAll
  447 + || (c.AppliedPartnerType == ScopeSpecified
  448 + && SqlFunc.Subqueryable<FlLabelCategoryPartnerDbEntity>()
  449 + .Where(p => p.CategoryId == c.Id && scopedPartnerIds.Contains(p.PartnerId))
  450 + .Any()));
422 451 }
423 452  
424 453 /// <summary>
... ... @@ -446,14 +475,48 @@ public static class LabelEntityPartnerScopeHelper
446 475 }
447 476  
448 477 return query.Where(o =>
449   - o.AppliedPartnerType == ScopeSpecified
450   - && SqlFunc.Subqueryable<FlLabelMultipleOptionPartnerDbEntity>()
451   - .Where(p => p.MultipleOptionId == o.Id && scopedPartnerIds.Contains(p.PartnerId))
452   - .Any());
  478 + o.AppliedPartnerType == ScopeAll
  479 + || (o.AppliedPartnerType == ScopeSpecified
  480 + && SqlFunc.Subqueryable<FlLabelMultipleOptionPartnerDbEntity>()
  481 + .Where(p => p.MultipleOptionId == o.Id && scopedPartnerIds.Contains(p.PartnerId))
  482 + .Any()));
  483 + }
  484 +
  485 + /// <summary>
  486 + /// 列表筛选:按可见 Company 过滤产品分类。
  487 + /// </summary>
  488 + public static async Task<ISugarQueryable<FlProductCategoryDbEntity>> ApplyProductCategoryPartnerListFilterAsync(
  489 + ISqlSugarClient db,
  490 + ISugarQueryable<FlProductCategoryDbEntity> query,
  491 + IReadOnlyList<string>? scopedPartnerIds)
  492 + {
  493 + if (scopedPartnerIds is null)
  494 + {
  495 + return query;
  496 + }
  497 +
  498 + var schema = await GetSchemaStatusAsync(db);
  499 + if (!HasPartnerTableForKind(schema, LabelEntityPartnerKind.ProductCategory))
  500 + {
  501 + return query;
  502 + }
  503 +
  504 + if (scopedPartnerIds.Count == 0)
  505 + {
  506 + return query.Where(_ => false);
  507 + }
  508 +
  509 + return query.Where(c =>
  510 + c.AppliedPartnerType == ScopeAll
  511 + || (c.AppliedPartnerType == ScopeSpecified
  512 + && SqlFunc.Subqueryable<FlProductCategoryPartnerDbEntity>()
  513 + .Where(p => p.CategoryId == c.Id && scopedPartnerIds.Contains(p.PartnerId))
  514 + .Any()));
453 515 }
454 516  
455 517 /// <summary>
456 518 /// 由 Query <c>partnerId</c> 解析列表 Company 筛选 Id;未传则返回 <c>null</c>。
  519 + /// Id 统一为小写 Guid,避免与关联表大小写不一致导致 IN 匹配失败。
457 520 /// </summary>
458 521 public static async Task<List<string>?> ResolveScopedPartnerIdsForListAsync(
459 522 ISqlSugarClient db,
... ... @@ -467,7 +530,13 @@ public static class LabelEntityPartnerScopeHelper
467 530  
468 531 var exists = await db.Queryable<FlPartnerDbEntity>()
469 532 .AnyAsync(x => !x.IsDeleted && x.Id == pid);
470   - return exists ? new List<string> { pid } : new List<string>();
  533 + if (!exists)
  534 + {
  535 + return new List<string>();
  536 + }
  537 +
  538 + var key = TeamMemberListScopeHelper.NormalizeScopeKey(pid);
  539 + return string.IsNullOrEmpty(key) ? new List<string> { pid } : new List<string> { key };
471 540 }
472 541  
473 542 /// <summary>
... ... @@ -514,6 +583,11 @@ public static class LabelEntityPartnerScopeHelper
514 583 .Where(x => x.MultipleOptionId == entityId)
515 584 .ExecuteCommandAsync();
516 585 break;
  586 + case LabelEntityPartnerKind.ProductCategory:
  587 + await db.Deleteable<FlProductCategoryPartnerDbEntity>()
  588 + .Where(x => x.CategoryId == entityId)
  589 + .ExecuteCommandAsync();
  590 + break;
517 591 }
518 592 }
519 593  
... ... @@ -564,7 +638,10 @@ public static class LabelEntityPartnerScopeHelper
564 638 db, "fl_label_multiple_option", "AppliedPartnerType"),
565 639 HasTypePartnerTable = await TableExistsAsync(db, "fl_label_type_partner"),
566 640 HasCategoryPartnerTable = await TableExistsAsync(db, "fl_label_category_partner"),
567   - HasMultipleOptionPartnerTable = await TableExistsAsync(db, "fl_label_multiple_option_partner")
  641 + HasMultipleOptionPartnerTable = await TableExistsAsync(db, "fl_label_multiple_option_partner"),
  642 + HasProductCategoryPartnerColumn = await ColumnExistsAsync(
  643 + db, "fl_product_category", "AppliedPartnerType"),
  644 + HasProductCategoryPartnerTable = await TableExistsAsync(db, "fl_product_category_partner")
568 645 };
569 646 }
570 647 catch
... ... @@ -581,6 +658,7 @@ public static class LabelEntityPartnerScopeHelper
581 658 LabelEntityPartnerKind.Type => schema.HasTypePartnerTable,
582 659 LabelEntityPartnerKind.Category => schema.HasCategoryPartnerTable,
583 660 LabelEntityPartnerKind.MultipleOption => schema.HasMultipleOptionPartnerTable,
  661 + LabelEntityPartnerKind.ProductCategory => schema.HasProductCategoryPartnerTable,
584 662 _ => false
585 663 };
586 664  
... ... @@ -590,6 +668,7 @@ public static class LabelEntityPartnerScopeHelper
590 668 LabelEntityPartnerKind.Type => schema.HasTypePartnerColumn,
591 669 LabelEntityPartnerKind.Category => schema.HasCategoryPartnerColumn,
592 670 LabelEntityPartnerKind.MultipleOption => schema.HasMultipleOptionPartnerColumn,
  671 + LabelEntityPartnerKind.ProductCategory => schema.HasProductCategoryPartnerColumn,
593 672 _ => false
594 673 };
595 674  
... ... @@ -637,6 +716,7 @@ public static class LabelEntityPartnerScopeHelper
637 716 LabelEntityPartnerKind.Type => "fl_label_type",
638 717 LabelEntityPartnerKind.Category => "fl_label_category",
639 718 LabelEntityPartnerKind.MultipleOption => "fl_label_multiple_option",
  719 + LabelEntityPartnerKind.ProductCategory => "fl_product_category",
640 720 _ => string.Empty
641 721 };
642 722  
... ... @@ -666,6 +746,7 @@ public static class LabelEntityPartnerScopeHelper
666 746 LabelEntityPartnerKind.Type => "fl_label_type",
667 747 LabelEntityPartnerKind.Category => "fl_label_category",
668 748 LabelEntityPartnerKind.MultipleOption => "fl_label_multiple_option",
  749 + LabelEntityPartnerKind.ProductCategory => "fl_product_category",
669 750 _ => string.Empty
670 751 };
671 752  
... ... @@ -709,6 +790,13 @@ public static class LabelEntityPartnerScopeHelper
709 790 .ToListAsync();
710 791 return rows.Select(x => (x.MultipleOptionId, x.PartnerId)).ToList();
711 792 }
  793 + case LabelEntityPartnerKind.ProductCategory:
  794 + {
  795 + var rows = await db.Queryable<FlProductCategoryPartnerDbEntity>()
  796 + .Where(x => entityIds.Contains(x.CategoryId))
  797 + .ToListAsync();
  798 + return rows.Select(x => (x.CategoryId, x.PartnerId)).ToList();
  799 + }
712 800 default:
713 801 return new List<(string EntityId, string PartnerId)>();
714 802 }
... ... @@ -764,6 +852,19 @@ public static class LabelEntityPartnerScopeHelper
764 852 await db.Insertable(rows).ExecuteCommandAsync();
765 853 break;
766 854 }
  855 + case LabelEntityPartnerKind.ProductCategory:
  856 + {
  857 + var rows = partnerIds.Select(pid => new FlProductCategoryPartnerDbEntity
  858 + {
  859 + Id = guidGenerator.Create().ToString(),
  860 + CategoryId = entityId,
  861 + PartnerId = pid,
  862 + CreationTime = now,
  863 + CreatorId = currentUserId
  864 + }).ToList();
  865 + await db.Insertable(rows).ExecuteCommandAsync();
  866 + break;
  867 + }
767 868 }
768 869 }
769 870  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelQueryHelper.cs
... ... @@ -28,6 +28,7 @@ public static class LabelQueryHelper
28 28 LabelName = x.LabelName,
29 29 TemplateId = x.TemplateId,
30 30 LocationId = x.LocationId,
  31 + PartnerId = x.PartnerId,
31 32 LabelCategoryId = x.LabelCategoryId,
32 33 LabelTypeId = x.LabelTypeId,
33 34 LabelType = x.LabelType,
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelRegionScopeHelper.cs
... ... @@ -58,12 +58,40 @@ public static class LabelRegionScopeHelper
58 58 IReadOnlyList<string>? regionIds,
59 59 IReadOnlyList<string>? groupIds,
60 60 string? locationId,
61   - IReadOnlyList<string>? locationIds)
  61 + IReadOnlyList<string>? locationIds,
  62 + IReadOnlyList<string>? partnerIdsForContext = null)
62 63 {
63   - var mergedRegionIds = NormalizeRegionIds(regionIds, groupIds);
64   - var explicitLocationIds = MergeExplicitLocationIds(locationId, locationIds);
  64 + var mergedRegionIdsRaw = NormalizeRegionIds(regionIds, groupIds);
  65 + var explicitLocationIdsRaw = MergeExplicitLocationIds(locationId, locationIds);
  66 + var regionHasAll = LocationScopeBindingHelper.ContainsAllScopeSentinel(mergedRegionIdsRaw);
  67 + var locationHasAll = LocationScopeBindingHelper.ContainsAllScopeSentinel(explicitLocationIdsRaw);
  68 + var mergedRegionIds = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedRegionIdsRaw);
  69 + var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(explicitLocationIdsRaw);
65 70 var type = (appliedRegionType ?? AppliedRegionAll).Trim().ToUpperInvariant();
66 71 var hasScopeArrays = regionIds is not null || groupIds is not null || locationIds is not null;
  72 + var partnerContext = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext);
  73 +
  74 + // locationIds / regionIds 含 ALL 哨兵:不归档为 Guid 校验,按全选处理(Company 由 fl_label.PartnerId 落库)
  75 + if (locationHasAll || (regionHasAll && explicitLocationIds.Count == 0))
  76 + {
  77 + return new LabelRegionScopeSaveResult
  78 + {
  79 + AppliedRegionType = AppliedRegionAll,
  80 + RegionIds = new List<string>(),
  81 + LocationIds = new List<string>()
  82 + };
  83 + }
  84 +
  85 + if (regionHasAll && explicitLocationIds.Count > 0)
  86 + {
  87 + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds);
  88 + return new LabelRegionScopeSaveResult
  89 + {
  90 + AppliedRegionType = AppliedRegionAll,
  91 + RegionIds = new List<string>(),
  92 + LocationIds = explicitLocationIds
  93 + };
  94 + }
67 95  
68 96 if (mergedRegionIds.Count > 0)
69 97 {
... ... @@ -95,8 +123,10 @@ public static class LabelRegionScopeHelper
95 123 await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds);
96 124 }
97 125  
98   - var partnerContext = await ResolvePartnerContextAsync(db, mergedRegionIds, explicitLocationIds);
99   - var allRegions = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, partnerContext);
  126 + var resolvedPartnerContext = partnerContext.Count > 0
  127 + ? partnerContext
  128 + : await ResolvePartnerContextAsync(db, mergedRegionIds, explicitLocationIds);
  129 + var allRegions = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, resolvedPartnerContext);
100 130 var regionsFull = mergedRegionIds.Count > 0
101 131 && AllScopeBindingHelper.IsFullIdSelection(mergedRegionIds, allRegions);
102 132  
... ... @@ -107,7 +137,7 @@ public static class LabelRegionScopeHelper
107 137 if (declaredOrFullAll)
108 138 {
109 139 var allLocationsForPartner = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(
110   - db, partnerContext, null);
  140 + db, resolvedPartnerContext, null);
111 141 var locationsFullForPartner = explicitLocationIds.Count == 0
112 142 || (allLocationsForPartner.Count > 0
113 143 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocationsForPartner));
... ... @@ -135,7 +165,7 @@ public static class LabelRegionScopeHelper
135 165 if (mergedRegionIds.Count == 0 && explicitLocationIds.Count > 0)
136 166 {
137 167 var allLocations = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(
138   - db, partnerContext, null);
  168 + db, resolvedPartnerContext, null);
139 169 if (allLocations.Count > 0
140 170 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocations))
141 171 {
... ... @@ -157,7 +187,7 @@ public static class LabelRegionScopeHelper
157 187  
158 188 // 部分 Region:门店 Select All(空或覆盖该区域全集)→ 只落 Region,不写门店快照(新区店靠 Region 动态匹配)
159 189 var allLocationsInRegions = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(
160   - db, null, mergedRegionIds);
  190 + db, resolvedPartnerContext, mergedRegionIds);
161 191 var locationsFullInRegions = explicitLocationIds.Count == 0
162 192 || (allLocationsInRegions.Count > 0
163 193 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocationsInRegions));
... ... @@ -179,7 +209,7 @@ public static class LabelRegionScopeHelper
179 209  
180 210 // 部分 Region + 部分门店 → 校验门店属于所选 Region 后落快照
181 211 var mergedLocations = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
182   - db, (IReadOnlyList<string>?)null, mergedRegionIds, explicitLocationIds);
  212 + db, resolvedPartnerContext, mergedRegionIds, explicitLocationIds);
183 213 if (mergedLocations.Count == 0)
184 214 {
185 215 throw new UserFriendlyException("指定 Region 下未匹配到有效门店,请检查 Region 或 locationIds");
... ... @@ -405,8 +435,8 @@ public static class LabelRegionScopeHelper
405 435 return new LabelRegionScopeDisplay
406 436 {
407 437 Region = AllRegionsDisplay,
408   - RegionIds = new List<string>(),
409   - GroupIds = new List<string>()
  438 + RegionIds = new List<string> { AllScopeBindingHelper.ScopeAll },
  439 + GroupIds = new List<string> { AllScopeBindingHelper.ScopeAll }
410 440 };
411 441 }
412 442  
... ... @@ -435,11 +465,18 @@ public static class LabelRegionScopeHelper
435 465 ? string.Join(", ", names.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!.Trim()).Distinct())
436 466 : "?";
437 467  
  468 + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync(
  469 + db,
  470 + null,
  471 + regionIds,
  472 + locationIds,
  473 + ScopeAllEchoHelper.ForLabelRegionScope(appliedRegionType, locationIds));
  474 +
438 475 return new LabelRegionScopeDisplay
439 476 {
440 477 Region = regionText,
441   - RegionIds = regionIds,
442   - GroupIds = regionIds
  478 + RegionIds = collapsed.RegionIds,
  479 + GroupIds = collapsed.RegionIds
443 480 };
444 481 }
445 482  
... ... @@ -454,7 +491,7 @@ public static class LabelRegionScopeHelper
454 491 return new LabelLocationScopeDisplay
455 492 {
456 493 Location = AllLocationsDisplay,
457   - LocationIds = new List<string>()
  494 + LocationIds = new List<string> { AllScopeBindingHelper.ScopeAll }
458 495 };
459 496 }
460 497  
... ... @@ -482,10 +519,17 @@ public static class LabelRegionScopeHelper
482 519 ? string.Join(", ", names.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!.Trim()).Distinct())
483 520 : "?";
484 521  
  522 + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync(
  523 + db,
  524 + null,
  525 + null,
  526 + locationIds,
  527 + ScopeAllEchoHelper.ForLabelRegionScope(appliedRegionType, locationIds));
  528 +
485 529 return new LabelLocationScopeDisplay
486 530 {
487 531 Location = locationText,
488   - LocationIds = locationIds.ToList()
  532 + LocationIds = collapsed.LocationIds
489 533 };
490 534 }
491 535  
... ... @@ -593,7 +637,24 @@ public static class LabelRegionScopeHelper
593 637 new { all = AppliedRegionAll, gid });
594 638 }
595 639  
596   - /// <summary>? scoped ?????<c>fl_label_location</c> ???? <c>LocationId</c>??</summary>
  640 + /// <summary>
  641 + /// 无可见门店时:仅保留 <c>AppliedRegionType=ALL</c> 的标签(用于 PartnerId 筛选但该公司暂无门店)。
  642 + /// </summary>
  643 + public static ISugarQueryable<FlLabelDbEntity> ApplyLabelAllRegionOnlyFilter(
  644 + ISqlSugarClient db,
  645 + ISugarQueryable<FlLabelDbEntity> query,
  646 + LabelRegionSchemaHelper.LabelRegionSchemaStatus schema)
  647 + {
  648 + if (!schema.HasAppliedRegionTypeColumn)
  649 + {
  650 + // 未迁移列时无法识别 ALL,保守返回空
  651 + return query.Where(_ => false);
  652 + }
  653 +
  654 + return query.Where("AppliedRegionType = @all", new { all = AppliedRegionAll });
  655 + }
  656 +
  657 + /// <summary>按 scoped 门店过滤;AppliedRegionType=ALL 对任意门店筛选均命中。</summary>
597 658 public static ISugarQueryable<FlLabelDbEntity> ApplyLabelLocationListFilter(
598 659 ISqlSugarClient db,
599 660 ISugarQueryable<FlLabelDbEntity> query,
... ... @@ -602,7 +663,7 @@ public static class LabelRegionScopeHelper
602 663 {
603 664 if (scopedLocationIds.Count == 0)
604 665 {
605   - return query.Where(_ => false);
  666 + return ApplyLabelAllRegionOnlyFilter(db, query, schema);
606 667 }
607 668  
608 669 return ApplyLabelLocationMatchFilter(query, scopedLocationIds, schema);
... ... @@ -648,6 +709,14 @@ public static class LabelRegionScopeHelper
648 709 var locationIds = await GetLocationIdsForLabelAsync(db, label.Id, label.LocationId);
649 710 if (locationIds.Count == 0)
650 711 {
  712 + // 指定 Company 的 ALL:仅该公司下门店可用
  713 + if (!string.IsNullOrWhiteSpace(label.PartnerId))
  714 + {
  715 + var locPartners = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
  716 + db, new List<string> { locationId.Trim() });
  717 + return locPartners.Contains(label.PartnerId.Trim(), StringComparer.OrdinalIgnoreCase);
  718 + }
  719 +
651 720 return true;
652 721 }
653 722  
... ... @@ -784,6 +853,26 @@ public static class LabelRegionScopeHelper
784 853 List<string> scopedLocationIds,
785 854 LabelRegionSchemaHelper.LabelRegionSchemaStatus schema)
786 855 {
  856 + if (schema.HasAppliedRegionTypeColumn && schema.HasLabelLocationTable)
  857 + {
  858 + return query.Where(
  859 + """
  860 + (AppliedRegionType = @all
  861 + OR LocationId IN (@locs)
  862 + OR EXISTS (SELECT 1 FROM fl_label_location ll WHERE ll.LabelId = fl_label.Id AND ll.LocationId IN (@locs)))
  863 + """,
  864 + new { all = AppliedRegionAll, locs = scopedLocationIds });
  865 + }
  866 +
  867 + if (schema.HasAppliedRegionTypeColumn)
  868 + {
  869 + return query.Where(
  870 + """
  871 + (AppliedRegionType = @all OR LocationId IN (@locs))
  872 + """,
  873 + new { all = AppliedRegionAll, locs = scopedLocationIds });
  874 + }
  875 +
787 876 if (schema.HasLabelLocationTable)
788 877 {
789 878 return query.Where(
... ... @@ -829,17 +918,18 @@ public static class LabelRegionScopeHelper
829 918  
830 919 private static async Task ValidateRegionIdsExistAsync(ISqlSugarClient db, List<string> regionIds)
831 920 {
832   - if (regionIds.Count == 0)
  921 + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds);
  922 + if (ids.Count == 0)
833 923 {
834 924 return;
835 925 }
836 926  
837 927 var count = await db.Queryable<FlGroupDbEntity>()
838   - .Where(g => !g.IsDeleted && regionIds.Contains(g.Id))
  928 + .Where(g => !g.IsDeleted && ids.Contains(g.Id))
839 929 .CountAsync();
840   - if (count != regionIds.Count)
  930 + if (count != ids.Count)
841 931 {
842   - throw new UserFriendlyException("????? Region Id???????");
  932 + throw new UserFriendlyException("存在无效的 Region Id,请刷新后重试");
843 933 }
844 934 }
845 935 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs
... ... @@ -58,43 +58,73 @@ public static class LabelTemplateScopeHelper
58 58  
59 59 /// <summary>
60 60 /// 解析新增/编辑入参中的 Company / Region / Location 范围。
  61 + /// Create/Update 共用;<c>regionIds</c>、<c>groupIds</c>、<c>locationIds</c>、<c>appliedLocationIds</c> 可传哨兵 <c>ALL</c>,
  62 + /// 即使 <c>appliedRegionType</c> / <c>appliedLocation</c> 为 <c>SPECIFIED</c> 也会归档为对应维度 <c>ALL</c> 且不写关联快照。
61 63 /// </summary>
62 64 public static async Task<LabelTemplateScopeSaveResult> ResolveScopeForSaveAsync(
63 65 ISqlSugarClient db,
64 66 LabelTemplateCreateInputVo input)
65 67 {
66   - var partnerIds = NormalizePartnerIds(input);
67   - var regionIds = NormalizeRegionIds(input);
68   - var locationIds = MergeExplicitLocationIds(input);
  68 + var mergedRegionIds = NormalizeRegionIds(input);
  69 + var mergedLocationIds = MergeExplicitLocationIds(input);
  70 + var hasRegionArray = input.RegionIds is not null || input.GroupIds is not null;
  71 + var hasLocationArray = input.LocationIds is not null || input.AppliedLocationIds is not null;
  72 +
  73 + // Create/Update 共用:优先识别 ALL 哨兵,避免后续 Count>0 或 Guid 存在性校验误伤编辑回显的 ["ALL"]
  74 + var locationHasAll = AllScopeBindingHelper.HasAllScopeSentinelSelection(mergedLocationIds);
  75 + var regionHasAll = AllScopeBindingHelper.HasAllScopeSentinelSelection(mergedRegionIds);
  76 + var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedLocationIds);
69 77  
70 78 var (partnerType, partnerIdsForSave) = await AllScopeBindingHelper.NormalizePartnerScopeAsync(
71 79 db,
72 80 input.AppliedPartnerType,
73   - partnerIds,
74   - null,
  81 + input.PartnerIds,
  82 + input.CompanyIds,
75 83 input.PartnerIds is not null || input.CompanyIds is not null);
76   - partnerIds = partnerIdsForSave;
  84 + var partnerIds = partnerIdsForSave;
77 85  
78 86 var partnerContext = string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
79 87 ? partnerIds
80 88 : null;
81 89  
82   - var (regionType, regionIdsForSave) = await AllScopeBindingHelper.NormalizeRegionScopeAsync(
83   - db,
84   - input.AppliedRegionType,
85   - regionIds,
86   - input.RegionIds is not null || input.GroupIds is not null,
87   - partnerContext);
88   - regionIds = regionIdsForSave;
  90 + string regionType;
  91 + List<string> regionIds;
  92 + if (regionHasAll && concreteLocations.Count == 0)
  93 + {
  94 + regionType = ScopeAll;
  95 + regionIds = new List<string>();
  96 + }
  97 + else
  98 + {
  99 + var regionResult = await AllScopeBindingHelper.NormalizeRegionScopeAsync(
  100 + db,
  101 + input.AppliedRegionType,
  102 + LocationScopeBindingHelper.FilterConcreteScopeIds(mergedRegionIds),
  103 + hasRegionArray,
  104 + partnerContext);
  105 + regionType = regionResult.Type;
  106 + regionIds = regionResult.Ids;
  107 + }
89 108  
90   - var (locationType, locationIdsForSave) = await AllScopeBindingHelper.NormalizeLocationScopeAsync(
91   - db,
92   - input.AppliedLocationType,
93   - locationIds,
94   - input.LocationIds is not null || input.AppliedLocationIds is not null,
95   - partnerContext,
96   - string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) ? regionIds : null);
97   - locationIds = locationIdsForSave;
  109 + string locationType;
  110 + List<string> locationIds;
  111 + if (locationHasAll)
  112 + {
  113 + locationType = ScopeAll;
  114 + locationIds = new List<string>();
  115 + }
  116 + else
  117 + {
  118 + var locationResult = await AllScopeBindingHelper.NormalizeLocationScopeAsync(
  119 + db,
  120 + input.AppliedLocationType,
  121 + concreteLocations,
  122 + hasLocationArray,
  123 + partnerContext,
  124 + string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) ? regionIds : null);
  125 + locationType = locationResult.Type;
  126 + locationIds = locationResult.Ids;
  127 + }
98 128  
99 129 ValidateDimensionType("Company", partnerType);
100 130 ValidateDimensionType("Region", regionType);
... ... @@ -134,7 +164,9 @@ public static class LabelTemplateScopeHelper
134 164 || string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
135 165 || string.Equals(locationType, ScopeSpecified, StringComparison.OrdinalIgnoreCase);
136 166  
137   - if (anySpecified)
  167 + if (anySpecified
  168 + && (string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
  169 + || string.Equals(locationType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)))
138 170 {
139 171 var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
140 172 db,
... ... @@ -300,6 +332,8 @@ public static class LabelTemplateScopeHelper
300 332 .Where(x => templateIds.Contains(x.TemplateId))
301 333 .ToListAsync();
302 334  
  335 + var storedScopeTypes = await LabelTemplateScopeSchemaHelper.GetAppliedScopeTypesMapAsync(db, templateIds);
  336 +
303 337 var partnerIdSet = partnerLinks.Select(x => x.PartnerId).Distinct(StringComparer.Ordinal).ToList();
304 338 var regionIdSet = regionLinks.Select(x => x.GroupId).Distinct(StringComparer.Ordinal).ToList();
305 339 var locationIdSet = locationLinks.Select(x => x.LocationId).Distinct(StringComparer.Ordinal).ToList();
... ... @@ -360,9 +394,16 @@ public static class LabelTemplateScopeHelper
360 394 var partnerType = pIds.Count > 0 ? ScopeSpecified : ScopeAll;
361 395 var regionType = rIds.Count > 0 ? ScopeSpecified : ScopeAll;
362 396  
  397 + if (storedScopeTypes.TryGetValue(template.Id, out var storedTypes))
  398 + {
  399 + partnerType = NormalizeScopeType(storedTypes.PartnerType, partnerType);
  400 + regionType = NormalizeScopeType(storedTypes.RegionType, regionType);
  401 + }
  402 +
363 403 if (hasExtendedScope
364 404 && pIds.Count == 0
365   - && lIds.Count > 0)
  405 + && lIds.Count > 0
  406 + && !string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))
366 407 {
367 408 pIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, lIds);
368 409 if (pIds.Count > 0)
... ... @@ -373,7 +414,8 @@ public static class LabelTemplateScopeHelper
373 414  
374 415 if (hasExtendedScope
375 416 && rIds.Count == 0
376   - && lIds.Count > 0)
  417 + && lIds.Count > 0
  418 + && !string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))
377 419 {
378 420 rIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(db, lIds);
379 421 if (rIds.Count > 0)
... ... @@ -401,6 +443,13 @@ public static class LabelTemplateScopeHelper
401 443 ? AllLocationsDisplay
402 444 : FormatLocationNames(lIds, locById);
403 445  
  446 + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync(
  447 + db,
  448 + pIds,
  449 + rIds,
  450 + lIds,
  451 + ScopeAllEchoHelper.ForLabelTemplateDimensions(partnerType, regionType, locationType));
  452 +
404 453 result[template.Id] = new LabelTemplateScopeDisplay
405 454 {
406 455 Company = companyDisplay,
... ... @@ -409,9 +458,9 @@ public static class LabelTemplateScopeHelper
409 458 AppliedPartnerType = partnerType,
410 459 AppliedRegionType = regionType,
411 460 AppliedLocationType = locationType,
412   - PartnerIds = pIds,
413   - RegionIds = rIds,
414   - LocationIds = lIds
  461 + PartnerIds = collapsed.PartnerIds,
  462 + RegionIds = collapsed.RegionIds,
  463 + LocationIds = collapsed.LocationIds
415 464 };
416 465 }
417 466  
... ... @@ -419,31 +468,58 @@ public static class LabelTemplateScopeHelper
419 468 }
420 469  
421 470 /// <summary>
422   - /// 列表权限:按当前用户可见门店筛选模板(各维度 AND)。
423   - /// <paramref name="scopedLocationIds"/> 为 <c>null</c> 时不限制(管理员且未传 Query 筛选);
424   - /// 非空时仅返回与可见 Company/Region/Location 匹配的模板,不包含未绑定任何范围的「全局」模板。
  471 + /// 列表筛选:按当前用户可见范围筛选模板。
  472 + /// 仅传 <c>partnerId</c> 时只按 Company 维度过滤(多公司绑定不会被门店 AND 误杀)。
425 473 /// </summary>
426 474 public static async Task<ISugarQueryable<FlLabelTemplateDbEntity>> ApplyTemplateScopeFilterAsync(
427 475 ISqlSugarClient db,
428 476 ISugarQueryable<FlLabelTemplateDbEntity> query,
429   - List<string>? scopedLocationIds)
  477 + List<string>? scopedLocationIds,
  478 + string? partnerId = null,
  479 + string? groupId = null,
  480 + string? locationId = null)
430 481 {
  482 + var hasExtendedScope = await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(db);
  483 + var schema = await LabelTemplateScopeSchemaHelper.GetStatusAsync(db);
  484 +
  485 + // 仅 PartnerId:只返回 fl_label_template_partner 含该公司的模板。
  486 + // 不含 AppliedPartnerType=ALL(全公司模板在未传 PartnerId 时可见);
  487 + // 也不把「无关联行」当成 ALL,避免其他公司数据漏出。
  488 + if (!LabelEntityListScopeHelper.ShouldApplyLocationAvailabilityFilter(partnerId, groupId, locationId)
  489 + && !string.IsNullOrWhiteSpace(partnerId))
  490 + {
  491 + var pid = partnerId.Trim();
  492 + if (!schema.HasPartnerScopeTable)
  493 + {
  494 + return query.Where(_ => false);
  495 + }
  496 +
  497 + return query.Where(t =>
  498 + SqlFunc.Subqueryable<FlLabelTemplatePartnerDbEntity>()
  499 + .Where(p => p.TemplateId == t.Id && p.PartnerId == pid)
  500 + .Any());
  501 + }
  502 +
431 503 if (scopedLocationIds is null)
432 504 {
433 505 return query;
434 506 }
435 507  
436   - var hasExtendedScope = await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(db);
437   -
438 508 if (scopedLocationIds.Count == 0)
439 509 {
440   - return query.Where(_ => false);
  510 + // 无可见门店:保留 Location=ALL(及无 location 关联)的模板
  511 + return query.Where(t =>
  512 + t.AppliedLocationType == ScopeAll
  513 + || !SqlFunc.Subqueryable<FlLabelTemplateLocationDbEntity>()
  514 + .Where(l => l.TemplateId == t.Id)
  515 + .Any());
441 516 }
442 517  
443 518 if (!hasExtendedScope)
444 519 {
445 520 return query.Where(t =>
446   - SqlFunc.Subqueryable<FlLabelTemplateLocationDbEntity>()
  521 + t.AppliedLocationType == ScopeAll
  522 + || SqlFunc.Subqueryable<FlLabelTemplateLocationDbEntity>()
447 523 .Where(l => l.TemplateId == t.Id && scopedLocationIds.Contains(l.LocationId))
448 524 .Any());
449 525 }
... ... @@ -453,6 +529,30 @@ public static class LabelTemplateScopeHelper
453 529 var scopedGroupIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
454 530 db, scopedLocationIds);
455 531  
  532 + // Company:AppliedPartnerType=ALL 或关联命中(不用「无行=ALL」)
  533 + if (schema.HasAppliedPartnerTypeColumn)
  534 + {
  535 + return query.Where(
  536 + """
  537 + (AppliedPartnerType = @all
  538 + OR EXISTS (SELECT 1 FROM fl_label_template_partner p
  539 + WHERE p.TemplateId = fl_label_template.Id AND p.PartnerId IN (@partnerIds)))
  540 + AND (NOT EXISTS (SELECT 1 FROM fl_label_template_region r WHERE r.TemplateId = fl_label_template.Id)
  541 + OR EXISTS (SELECT 1 FROM fl_label_template_region r
  542 + WHERE r.TemplateId = fl_label_template.Id AND r.GroupId IN (@groupIds)))
  543 + AND (AppliedLocationType = @all
  544 + OR EXISTS (SELECT 1 FROM fl_label_template_location l
  545 + WHERE l.TemplateId = fl_label_template.Id AND l.LocationId IN (@locs)))
  546 + """,
  547 + new
  548 + {
  549 + all = ScopeAll,
  550 + partnerIds = scopedPartnerIds,
  551 + groupIds = scopedGroupIds.Count > 0 ? scopedGroupIds : new List<string> { "__none__" },
  552 + locs = scopedLocationIds
  553 + });
  554 + }
  555 +
456 556 return query.Where(t =>
457 557 SqlFunc.Subqueryable<FlLabelTemplatePartnerDbEntity>()
458 558 .Where(p => p.TemplateId == t.Id && scopedPartnerIds.Contains(p.PartnerId))
... ... @@ -567,15 +667,16 @@ public static class LabelTemplateScopeHelper
567 667  
568 668 private static async Task ValidateRegionIdsExistAsync(ISqlSugarClient db, List<string> regionIds)
569 669 {
570   - if (regionIds.Count == 0)
  670 + var ids = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds);
  671 + if (ids.Count == 0)
571 672 {
572 673 return;
573 674 }
574 675  
575 676 var count = await db.Queryable<FlGroupDbEntity>()
576   - .Where(x => !x.IsDeleted && regionIds.Contains(x.Id))
  677 + .Where(x => !x.IsDeleted && ids.Contains(x.Id))
577 678 .CountAsync();
578   - if (count != regionIds.Count)
  679 + if (count != ids.Count)
579 680 {
580 681 throw new UserFriendlyException("存在无效的 Region(regionIds/groupIds),请刷新后重试");
581 682 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeSchemaHelper.cs
... ... @@ -315,6 +315,77 @@ public static class LabelTemplateScopeSchemaHelper
315 315 return map.TryGetValue(templateId.Trim(), out var text) ? text : null;
316 316 }
317 317  
  318 + /// <summary>批量读取模板 Company/Region 维度 ALL/SPECIFIED;列不存在时返回空字典。</summary>
  319 + public static async Task<Dictionary<string, (string PartnerType, string RegionType)>> GetAppliedScopeTypesMapAsync(
  320 + ISqlSugarClient db,
  321 + IReadOnlyList<string> templateIds)
  322 + {
  323 + var result = new Dictionary<string, (string PartnerType, string RegionType)>(StringComparer.Ordinal);
  324 + if (templateIds.Count == 0)
  325 + {
  326 + return result;
  327 + }
  328 +
  329 + var status = await GetStatusAsync(db);
  330 + if (!status.HasAppliedPartnerTypeColumn && !status.HasAppliedRegionTypeColumn)
  331 + {
  332 + return result;
  333 + }
  334 +
  335 + var ids = templateIds.Where(x => !string.IsNullOrWhiteSpace(x))
  336 + .Select(x => x.Trim())
  337 + .Distinct(StringComparer.Ordinal)
  338 + .ToArray();
  339 + if (ids.Length == 0)
  340 + {
  341 + return result;
  342 + }
  343 +
  344 + var rows = await db.Ado.SqlQueryAsync<AppliedScopeTypesRow>(
  345 + """
  346 + SELECT Id, AppliedPartnerType, AppliedRegionType
  347 + FROM fl_label_template
  348 + WHERE Id IN (@ids)
  349 + """,
  350 + new { ids });
  351 +
  352 + foreach (var row in rows)
  353 + {
  354 + if (string.IsNullOrWhiteSpace(row.Id))
  355 + {
  356 + continue;
  357 + }
  358 +
  359 + var partnerType = status.HasAppliedPartnerTypeColumn
  360 + ? NormalizeScopeTypeValue(row.AppliedPartnerType)
  361 + : ScopeAll;
  362 + var regionType = status.HasAppliedRegionTypeColumn
  363 + ? NormalizeScopeTypeValue(row.AppliedRegionType)
  364 + : ScopeAll;
  365 + result[row.Id.Trim()] = (partnerType, regionType);
  366 + }
  367 +
  368 + return result;
  369 + }
  370 +
  371 + private static string NormalizeScopeTypeValue(string? type)
  372 + {
  373 + var normalized = (type ?? ScopeAll).Trim().ToUpperInvariant();
  374 + return normalized == ScopeSpecified ? ScopeSpecified : ScopeAll;
  375 + }
  376 +
  377 + private const string ScopeAll = "ALL";
  378 + private const string ScopeSpecified = "SPECIFIED";
  379 +
  380 + private sealed class AppliedScopeTypesRow
  381 + {
  382 + public string Id { get; init; } = string.Empty;
  383 +
  384 + public string? AppliedPartnerType { get; init; }
  385 +
  386 + public string? AppliedRegionType { get; init; }
  387 + }
  388 +
318 389 /// <summary>已迁移 <c>Contents</c> 列时写入(未迁移则 no-op)。</summary>
319 390 public static async Task SetContentsAsync(ISqlSugarClient db, string templateId, string? contents)
320 391 {
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs
... ... @@ -306,13 +306,14 @@ public static class LocationScopeBindingHelper
306 306 merged.Add(id);
307 307 }
308 308  
309   - var fromGroups = await ResolveLocationIdsFromGroupIdsAsync(db, groupIds);
  309 + var concreteGroupIds = FilterConcreteScopeIds(groupIds);
  310 + var fromGroups = await ResolveLocationIdsFromGroupIdsAsync(db, concreteGroupIds);
310 311 foreach (var id in fromGroups)
311 312 {
312 313 merged.Add(id);
313 314 }
314 315  
315   - foreach (var id in NormalizeIds(locationIds))
  316 + foreach (var id in FilterConcreteScopeIds(locationIds))
316 317 {
317 318 merged.Add(id);
318 319 }
... ... @@ -321,6 +322,113 @@ public static class LocationScopeBindingHelper
321 322 }
322 323  
323 324 /// <summary>
  325 + /// 多选 Id 是否含 <c>ALL</c> 哨兵(大小写不敏感)。与具体 Guid 同传时以 ALL 为准(全选)。
  326 + /// </summary>
  327 + public static bool IsAllScopeSentinel(string? value) =>
  328 + string.Equals(value?.Trim(), AllScopeBindingHelper.ScopeAll, StringComparison.OrdinalIgnoreCase);
  329 +
  330 + /// <summary>Id 列表是否含 <c>ALL</c> 哨兵。</summary>
  331 + public static bool ContainsAllScopeSentinel(IReadOnlyList<string>? ids) =>
  332 + ids?.Any(IsAllScopeSentinel) == true;
  333 +
  334 + /// <summary>去掉 ALL 哨兵,仅保留具体 Id。</summary>
  335 + public static List<string> FilterConcreteScopeIds(IReadOnlyList<string>? ids) =>
  336 + NormalizeIds(ids).Where(x => !IsAllScopeSentinel(x)).ToList();
  337 +
  338 + /// <summary>
  339 + /// Team Member 门店范围落库(不做 partner+region+location 并集)。
  340 + /// 优先级:
  341 + /// 1) <c>locationIds</c> 含 ALL → 公司全部门店;
  342 + /// 2) 有具体 <c>locationIds</c>,且 <c>regionIds</c> 为空或为 ALL → 只绑这些门店(UI 在 Region=ALL 下再收窄门店);
  343 + /// 3) <c>regionIds</c> 含 ALL → 公司全部 Region 下门店;
  344 + /// 4) 具体 <c>regionIds</c> → 按 Region 展开(忽略同传 locationIds,保证多区域能落库);
  345 + /// 5) 仅 <paramref name="partnerIds"/> → 公司全部门店。
  346 + /// </summary>
  347 + public static async Task<List<string>> ResolveTeamMemberLocationIdsForSaveAsync(
  348 + ISqlSugarClient db,
  349 + IReadOnlyList<string>? partnerIds,
  350 + IReadOnlyList<string>? regionIds,
  351 + IReadOnlyList<string>? locationIds)
  352 + {
  353 + var normalizedPartners = NormalizeIds(partnerIds);
  354 + var locationHasAll = ContainsAllScopeSentinel(locationIds);
  355 + var concreteLocations = FilterConcreteScopeIds(locationIds);
  356 + var regionHasAll = ContainsAllScopeSentinel(regionIds);
  357 + var concreteRegions = FilterConcreteScopeIds(regionIds);
  358 +
  359 + // 1. locationIds 含 ALL:有具体 Region 时展开该 Region;否则按 Company 全部门店
  360 + if (locationHasAll)
  361 + {
  362 + var expanded = await ExpandScopedAllLocationsForSaveAsync(
  363 + db,
  364 + normalizedPartners.Count > 0 ? normalizedPartners : null,
  365 + concreteRegions.Count > 0 ? concreteRegions : null);
  366 + if (expanded is not null)
  367 + {
  368 + return expanded;
  369 + }
  370 +
  371 + throw new UserFriendlyException("选择全部门店时需指定 Company(partnerId / partnerIds)或 Region");
  372 + }
  373 +
  374 + // 2. 具体门店 +(无区域 / 区域为 ALL)→ 只绑这些门店,避免 regionIds=ALL 盖掉单店
  375 + if (concreteLocations.Count > 0 && (regionHasAll || concreteRegions.Count == 0))
  376 + {
  377 + return concreteLocations;
  378 + }
  379 +
  380 + // 3. regionIds 含 ALL(且无具体门店)→ 按 partner 展开全部 Region 下门店
  381 + if (regionHasAll)
  382 + {
  383 + if (normalizedPartners.Count == 0)
  384 + {
  385 + throw new UserFriendlyException("选择全部 Region 时需指定 Company(partnerId / partnerIds)");
  386 + }
  387 +
  388 + return await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, normalizedPartners, null);
  389 + }
  390 +
  391 + // 4. 具体 regionIds
  392 + if (concreteRegions.Count > 0)
  393 + {
  394 + var fromRegions = await ExpandScopedAllLocationsForSaveAsync(
  395 + db,
  396 + normalizedPartners.Count > 0 ? normalizedPartners : null,
  397 + concreteRegions);
  398 + var regionLocs = fromRegions ?? new List<string>();
  399 +
  400 + // 同传具体门店:若已覆盖该区全集(前端 Location=ALL 常展开 Guid)→ 用区内全部门店;
  401 + // 否则保留子集(Region 具体 + 部分门店)
  402 + if (concreteLocations.Count > 0)
  403 + {
  404 + if (regionLocs.Count > 0
  405 + && AllScopeBindingHelper.IsFullIdSelection(concreteLocations, regionLocs))
  406 + {
  407 + return regionLocs;
  408 + }
  409 +
  410 + return concreteLocations;
  411 + }
  412 +
  413 + return regionLocs;
  414 + }
  415 +
  416 + // 5. 仅有具体 locationIds → 只绑这些门店
  417 + if (concreteLocations.Count > 0)
  418 + {
  419 + return concreteLocations;
  420 + }
  421 +
  422 + // 6. 仅有 partner → 绑该公司全部门店
  423 + if (normalizedPartners.Count > 0)
  424 + {
  425 + return await MergeToLocationIdsAsync(db, normalizedPartners, null, null);
  426 + }
  427 +
  428 + return new List<string>();
  429 + }
  430 +
  431 + /// <summary>
324 432 /// 标签类型/分类/多选项门店范围落库:仅按 Region/Location 合并,不因 Company(partner)展开并集。
325 433 /// </summary>
326 434 public static async Task<List<string>> ResolveEntityLocationIdsForSaveAsync(
... ... @@ -334,11 +442,28 @@ public static class LocationScopeBindingHelper
334 442 return new List<string>();
335 443 }
336 444  
  445 + if (ContainsAllScopeSentinel(locationIds)
  446 + || (ContainsAllScopeSentinel(regionIds)
  447 + && FilterConcreteScopeIds(locationIds).Count == 0))
  448 + {
  449 + throw new UserFriendlyException("门店范围含 ALL 哨兵时应归档为 ALL,不应进入 SPECIFIED 落库校验");
  450 + }
  451 +
  452 + var concreteRegions = FilterConcreteScopeIds(regionIds);
  453 + var concreteLocations = FilterConcreteScopeIds(locationIds);
  454 +
  455 + // 有具体门店时以门店为准(不再与 Region 并集展开),避免「选 1 个 Location + 同传 Region」落成整 Region 再回显成 ALL
  456 + if (concreteLocations.Count > 0)
  457 + {
  458 + await ValidateLocationIdsExistAsync(db, concreteLocations);
  459 + return concreteLocations;
  460 + }
  461 +
337 462 var merged = await MergeToLocationIdsAsync(
338 463 db,
339 464 (IReadOnlyList<string>?)null,
340   - regionIds,
341   - locationIds);
  465 + concreteRegions,
  466 + concreteLocations);
342 467 if (merged.Count == 0)
343 468 {
344 469 throw new UserFriendlyException("指定适用区域或门店时,至少需要匹配到一个有效门店");
... ... @@ -349,6 +474,52 @@ public static class LocationScopeBindingHelper
349 474 }
350 475  
351 476 /// <summary>
  477 + /// 将「范围内 ALL」展开为门店 Id:优先按具体 Region,其次按具体 Company;两者皆无则返回 null(调用方归档全局 ALL)。
  478 + /// </summary>
  479 + public static async Task<List<string>?> ExpandScopedAllLocationsForSaveAsync(
  480 + ISqlSugarClient db,
  481 + IReadOnlyList<string>? partnerIds,
  482 + IReadOnlyList<string>? regionIds)
  483 + {
  484 + var concreteRegions = FilterConcreteScopeIds(regionIds);
  485 + if (concreteRegions.Count > 0)
  486 + {
  487 + var fromRegions = await ResolveLocationIdsFromGroupIdsAsync(db, concreteRegions);
  488 + var partners = NormalizeIds(partnerIds);
  489 + if (partners.Count > 0)
  490 + {
  491 + var partnerLocSet = new HashSet<string>(
  492 + await ResolveLocationIdsFromPartnerIdsAsync(db, partners),
  493 + StringComparer.OrdinalIgnoreCase);
  494 + fromRegions = fromRegions.Where(id => partnerLocSet.Contains(id)).ToList();
  495 + }
  496 +
  497 + if (fromRegions.Count == 0)
  498 + {
  499 + throw new UserFriendlyException("指定 Region 下未匹配到有效门店");
  500 + }
  501 +
  502 + await ValidateLocationIdsExistAsync(db, fromRegions);
  503 + return fromRegions;
  504 + }
  505 +
  506 + var concretePartners = NormalizeIds(partnerIds);
  507 + if (concretePartners.Count > 0)
  508 + {
  509 + var fromPartners = await ResolveLocationIdsFromPartnerIdsAsync(db, concretePartners);
  510 + if (fromPartners.Count == 0)
  511 + {
  512 + throw new UserFriendlyException("指定 Company 下未匹配到有效门店");
  513 + }
  514 +
  515 + await ValidateLocationIdsExistAsync(db, fromPartners);
  516 + return fromPartners;
  517 + }
  518 +
  519 + return null;
  520 + }
  521 +
  522 + /// <summary>
352 523 /// 根据已绑定门店反推适用的 Company Id(<c>fl_partner.Id</c>)。
353 524 /// </summary>
354 525 public static async Task<List<string>> ResolvePartnerIdsFromLocationIdsAsync(
... ... @@ -591,7 +762,7 @@ public static class LocationScopeBindingHelper
591 762 /// </summary>
592 763 public static async Task ValidateLocationIdsExistAsync(ISqlSugarClient db, IReadOnlyList<string> locationIds)
593 764 {
594   - var ids = NormalizeIds(locationIds);
  765 + var ids = FilterConcreteScopeIds(locationIds);
595 766 if (ids.Count == 0)
596 767 {
597 768 return;
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/PlatformMenuHelper.cs 0 → 100644
  1 +using FoodLabeling.Application.Services.DbModels;
  2 +
  3 +namespace FoodLabeling.Application.Helpers;
  4 +
  5 +/// <summary>
  6 +/// 平台端菜单判定(与 ThSaasMenuPermissionCatalog.IsPlatformOnlyMenu 一致,避免 Application 依赖 Th 程序集)
  7 +/// </summary>
  8 +public static class PlatformMenuHelper
  9 +{
  10 + /// <summary>
  11 + /// 是否为仅平台端菜单(不可分配给公司)
  12 + /// </summary>
  13 + public static bool IsPlatformOnlyMenu(MenuDbEntity? menu)
  14 + {
  15 + if (menu == null)
  16 + {
  17 + return true;
  18 + }
  19 +
  20 + var code = menu.PermissionCode?.Trim() ?? string.Empty;
  21 + if (code.StartsWith("menu.platform", StringComparison.OrdinalIgnoreCase))
  22 + {
  23 + return true;
  24 + }
  25 +
  26 + var router = menu.Router?.Trim() ?? string.Empty;
  27 + return router.StartsWith("/platform", StringComparison.OrdinalIgnoreCase);
  28 + }
  29 +
  30 + /// <summary>
  31 + /// 从全量菜单中保留平台菜单及其祖先节点,以便组装菜单树
  32 + /// </summary>
  33 + public static List<MenuDbEntity> FilterPlatformMenusWithAncestors(IReadOnlyList<MenuDbEntity> allMenus)
  34 + {
  35 + if (allMenus.Count == 0)
  36 + {
  37 + return new List<MenuDbEntity>();
  38 + }
  39 +
  40 + var byId = allMenus
  41 + .GroupBy(m => m.Id, StringComparer.OrdinalIgnoreCase)
  42 + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
  43 +
  44 + var keepIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  45 + foreach (var menu in allMenus.Where(IsPlatformOnlyMenu))
  46 + {
  47 + IncludeWithAncestors(menu.Id, byId, keepIds);
  48 + }
  49 +
  50 + return allMenus.Where(m => keepIds.Contains(m.Id)).ToList();
  51 + }
  52 +
  53 + private static void IncludeWithAncestors(
  54 + string menuId,
  55 + IReadOnlyDictionary<string, MenuDbEntity> byId,
  56 + HashSet<string> keepIds)
  57 + {
  58 + if (!byId.TryGetValue(menuId, out var current))
  59 + {
  60 + return;
  61 + }
  62 +
  63 + if (!keepIds.Add(current.Id))
  64 + {
  65 + return;
  66 + }
  67 +
  68 + var parentId = current.ParentId?.Trim() ?? "0";
  69 + if (parentId == "0" || parentId == Guid.Empty.ToString())
  70 + {
  71 + return;
  72 + }
  73 +
  74 + IncludeWithAncestors(parentId, byId, keepIds);
  75 + }
  76 +
  77 + /// <summary>
  78 + /// 仅保留公司已开通菜单及其祖先节点(租户业务库 menu 表可能含 Seed 全量菜单)。
  79 + /// </summary>
  80 + public static List<MenuDbEntity> FilterCompanyMenusWithAncestors(
  81 + IReadOnlyList<MenuDbEntity> allMenus,
  82 + IReadOnlySet<string> enabledMenuIds)
  83 + {
  84 + if (allMenus.Count == 0 || enabledMenuIds.Count == 0)
  85 + {
  86 + return new List<MenuDbEntity>();
  87 + }
  88 +
  89 + var byId = allMenus
  90 + .Where(m => !m.IsDeleted && !IsPlatformOnlyMenu(m))
  91 + .GroupBy(m => m.Id, StringComparer.OrdinalIgnoreCase)
  92 + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
  93 +
  94 + var keepIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  95 + foreach (var menuId in enabledMenuIds)
  96 + {
  97 + IncludeWithAncestors(menuId?.Trim() ?? string.Empty, byId, keepIds);
  98 + }
  99 +
  100 + return allMenus.Where(m => keepIds.Contains(m.Id)).ToList();
  101 + }
  102 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs
... ... @@ -67,6 +67,18 @@ public static class ReportsPrintLogExpiryHelper
67 67 public static string ExtractFormattedExpiryText(string? printInputJson) =>
68 68 ExtractFormattedExpiryText(printInputJson, null, null, null);
69 69  
  70 + /// <summary>与 Print Log Expiration 同源:解析绝对过期时刻。</summary>
  71 + public static bool TryResolveExpiryDateTime(
  72 + string? printInputJson,
  73 + string? renderTemplateJson,
  74 + DateTime? baseTime,
  75 + DateTime? printedAt,
  76 + out DateTime expiresAt)
  77 + {
  78 + var reference = baseTime ?? printedAt ?? DateTime.Now;
  79 + return TryResolveExpiryDateTime(printInputJson, renderTemplateJson, reference, out expiresAt);
  80 + }
  81 +
70 82 private static bool TryResolveExpiryDateTime(
71 83 string? printInputJson,
72 84 string? renderTemplateJson,
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs
  1 +using SqlSugar;
1 2 using Volo.Abp.Users;
2 3 using Yi.Framework.Rbac.Domain.Shared.Consts;
3 4  
... ... @@ -66,4 +67,28 @@ public static class ReportsRoleHelper
66 67  
67 68 return false;
68 69 }
  70 +
  71 + /// <summary>
  72 + /// App JWT 未写入角色声明时,按业务库 UserRole/Role 判断是否管理员。
  73 + /// </summary>
  74 + public static async Task<bool> IsAdminRoleFromDbAsync(ISqlSugarClient db, Guid userId)
  75 + {
  76 + var rows = await db.Ado.SqlQueryAsync<AdminRoleCodeRow>(
  77 + """
  78 + SELECT r.RoleCode
  79 + FROM UserRole ur
  80 + INNER JOIN Role r ON ur.RoleId = r.Id
  81 + WHERE ur.UserId = @UserId AND r.IsDeleted = 0 AND r.State = 1
  82 + """,
  83 + new { UserId = userId });
  84 +
  85 + return rows.Any(r =>
  86 + !string.IsNullOrWhiteSpace(r.RoleCode) &&
  87 + string.Equals(r.RoleCode.Trim(), UserConst.AdminRolesCode, StringComparison.OrdinalIgnoreCase));
  88 + }
  89 +
  90 + private sealed class AdminRoleCodeRow
  91 + {
  92 + public string? RoleCode { get; set; }
  93 + }
69 94 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ScopeAllEchoHelper.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Helpers;
  4 +
  5 +/// <summary>
  6 +/// 编辑/列表回显:库中可存展开 Guid;若绑定覆盖全集则将 Id 数组折叠为 <c>["ALL"]</c> 哨兵(对齐 Team Member)。
  7 +/// </summary>
  8 +public static class ScopeAllEchoHelper
  9 +{
  10 + public sealed class ScopeAllEchoOptions
  11 + {
  12 + public bool IsPartnerAll { get; init; }
  13 +
  14 + public bool IsRegionAll { get; init; }
  15 +
  16 + public bool IsLocationAll { get; init; }
  17 +
  18 + /// <summary>落库维度为 SPECIFIED 时禁止再按「恰好全选」折叠为 ALL 哨兵。</summary>
  19 + public string? AppliedPartnerType { get; init; }
  20 +
  21 + public string? AppliedRegionType { get; init; }
  22 +
  23 + public string? AppliedLocationType { get; init; }
  24 + }
  25 +
  26 + /// <summary>
  27 + /// 将 partnerIds / regionIds / locationIds 折叠为 ALL 哨兵(编辑弹窗与列表 Id 数组回显)。
  28 + /// </summary>
  29 + public static async Task<(List<string> PartnerIds, List<string> RegionIds, List<string> LocationIds)>
  30 + CollapseScopeIdsToAllSentinelAsync(
  31 + ISqlSugarClient db,
  32 + IReadOnlyList<string>? partnerIds,
  33 + IReadOnlyList<string>? regionIds,
  34 + IReadOnlyList<string>? locationIds,
  35 + ScopeAllEchoOptions? options = null)
  36 + {
  37 + options ??= new ScopeAllEchoOptions();
  38 + var partners = LocationScopeBindingHelper.NormalizeIds(partnerIds);
  39 + var regions = LocationScopeBindingHelper.NormalizeIds(regionIds);
  40 + var locations = LocationScopeBindingHelper.NormalizeIds(locationIds);
  41 +
  42 + if (options.IsPartnerAll)
  43 + {
  44 + partners = new List<string> { AllScopeBindingHelper.ScopeAll };
  45 + }
  46 + else if (!AllScopeBindingHelper.IsDeclaredSpecified(options.AppliedPartnerType) && partners.Count > 0)
  47 + {
  48 + var allPartners = await AllScopeBindingHelper.ResolveAllPartnerIdsAsync(db);
  49 + if (allPartners.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(partners, allPartners))
  50 + {
  51 + partners = new List<string> { AllScopeBindingHelper.ScopeAll };
  52 + }
  53 + }
  54 +
  55 + if (options.IsRegionAll)
  56 + {
  57 + regions = new List<string> { AllScopeBindingHelper.ScopeAll };
  58 + }
  59 + else if (!AllScopeBindingHelper.IsDeclaredSpecified(options.AppliedRegionType) && regions.Count > 0)
  60 + {
  61 + regions = await CollapseRegionsIfFullAsync(db, partners, regions);
  62 + }
  63 +
  64 + if (options.IsLocationAll)
  65 + {
  66 + locations = new List<string> { AllScopeBindingHelper.ScopeAll };
  67 + }
  68 + else if (!AllScopeBindingHelper.IsDeclaredSpecified(options.AppliedLocationType) && locations.Count > 0)
  69 + {
  70 + locations = await CollapseLocationsIfFullAsync(db, partners, regions, locations);
  71 + }
  72 +
  73 + return (partners, regions, locations);
  74 + }
  75 +
  76 + /// <summary>AvailabilityType=ALL 时 Region 与 Location 均为 ALL。</summary>
  77 + public static ScopeAllEchoOptions ForAvailabilityAll(bool isAll) =>
  78 + new() { IsRegionAll = isAll, IsLocationAll = isAll };
  79 +
  80 + /// <summary>LabelTemplate 各维度独立 ALL 标记。</summary>
  81 + public static ScopeAllEchoOptions ForLabelTemplateDimensions(
  82 + string? appliedPartnerType,
  83 + string? appliedRegionType,
  84 + string? appliedLocationType) =>
  85 + new()
  86 + {
  87 + IsPartnerAll = AllScopeBindingHelper.IsDeclaredAll(appliedPartnerType),
  88 + IsRegionAll = AllScopeBindingHelper.IsDeclaredAll(appliedRegionType),
  89 + IsLocationAll = AllScopeBindingHelper.IsDeclaredAll(appliedLocationType),
  90 + AppliedPartnerType = appliedPartnerType,
  91 + AppliedRegionType = appliedRegionType,
  92 + AppliedLocationType = appliedLocationType
  93 + };
  94 +
  95 + /// <summary>标签类型/分类/多选项:Company + Region + Location 可用范围。</summary>
  96 + public static ScopeAllEchoOptions ForLabelEntityScope(
  97 + string? appliedPartnerType,
  98 + string? appliedRegionType,
  99 + string? availabilityType) =>
  100 + new()
  101 + {
  102 + IsPartnerAll = AllScopeBindingHelper.IsDeclaredAll(appliedPartnerType),
  103 + IsRegionAll = AllScopeBindingHelper.IsDeclaredAll(appliedRegionType),
  104 + IsLocationAll = AllScopeBindingHelper.IsDeclaredAll(availabilityType),
  105 + AppliedPartnerType = appliedPartnerType,
  106 + AppliedRegionType = appliedRegionType,
  107 + AppliedLocationType = availabilityType
  108 + };
  109 +
  110 + /// <summary>兼容旧调用:无 Region 类型时用 AvailabilityType 同时驱动 Region/Location。</summary>
  111 + public static ScopeAllEchoOptions ForLabelEntityScope(
  112 + string? appliedPartnerType,
  113 + string? availabilityType) =>
  114 + ForLabelEntityScope(appliedPartnerType, availabilityType, availabilityType);
  115 +
  116 + /// <summary>Label:AppliedRegionType=ALL 且未落门店快照时 Region/Location 均为 ALL。</summary>
  117 + public static ScopeAllEchoOptions ForLabelRegionScope(
  118 + string? appliedRegionType,
  119 + IReadOnlyList<string> locationIds)
  120 + {
  121 + var isRegionAll = AllScopeBindingHelper.IsDeclaredAll(appliedRegionType);
  122 + var normalizedLocations = LocationScopeBindingHelper.NormalizeIds(locationIds);
  123 + var isLocationAll = isRegionAll && normalizedLocations.Count == 0;
  124 + return new ScopeAllEchoOptions
  125 + {
  126 + IsRegionAll = isRegionAll,
  127 + IsLocationAll = isLocationAll,
  128 + AppliedRegionType = appliedRegionType,
  129 + AppliedLocationType = isLocationAll
  130 + ? AllScopeBindingHelper.ScopeAll
  131 + : AllScopeBindingHelper.ScopeSpecified
  132 + };
  133 + }
  134 +
  135 + private static async Task<List<string>> CollapseRegionsIfFullAsync(
  136 + ISqlSugarClient db,
  137 + IReadOnlyList<string> partners,
  138 + IReadOnlyList<string> regions)
  139 + {
  140 + var partnerContext = ResolvePartnerContextForCollapse(partners);
  141 + var allRegionIds = partnerContext is { Count: > 0 }
  142 + ? await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(db, partnerContext)
  143 + : await AllScopeBindingHelper.ResolveAllRegionIdsAsync(db, null);
  144 +
  145 + if (allRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(regions, allRegionIds))
  146 + {
  147 + return new List<string> { AllScopeBindingHelper.ScopeAll };
  148 + }
  149 +
  150 + return regions.ToList();
  151 + }
  152 +
  153 + private static async Task<List<string>> CollapseLocationsIfFullAsync(
  154 + ISqlSugarClient db,
  155 + IReadOnlyList<string> partners,
  156 + IReadOnlyList<string> regions,
  157 + IReadOnlyList<string> locations)
  158 + {
  159 + var partnerContext = ResolvePartnerContextForCollapse(partners);
  160 + List<string> allLocationIds;
  161 +
  162 + if (regions.Count == 1 && AllScopeBindingHelper.IsDeclaredAll(regions[0]))
  163 + {
  164 + allLocationIds = partnerContext is { Count: > 0 }
  165 + ? await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerContext)
  166 + : await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, null, null);
  167 + }
  168 + else if (regions.Count > 0 && !regions.Any(r => AllScopeBindingHelper.IsDeclaredAll(r)))
  169 + {
  170 + allLocationIds = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partnerContext, regions);
  171 + }
  172 + else if (partnerContext is { Count: > 0 })
  173 + {
  174 + allLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerContext);
  175 + }
  176 + else
  177 + {
  178 + allLocationIds = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, null, null);
  179 + }
  180 +
  181 + if (allLocationIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(locations, allLocationIds))
  182 + {
  183 + // 具体 Region:覆盖该区全部门店时一律回显 ["ALL"](含区内仅 1 店;前端 ALL 常展开为具体 Guid)
  184 + var hasConcreteRegions = regions.Count > 0
  185 + && !regions.Any(AllScopeBindingHelper.IsDeclaredAll);
  186 + if (hasConcreteRegions)
  187 + {
  188 + return new List<string> { AllScopeBindingHelper.ScopeAll };
  189 + }
  190 +
  191 + // Region=ALL(或无具体 Region)时:仅多店才折叠,避免「Region=ALL + 选 1 店」误成 Location ALL
  192 + if (locations.Count > 1 || allLocationIds.Count > 1)
  193 + {
  194 + return new List<string> { AllScopeBindingHelper.ScopeAll };
  195 + }
  196 + }
  197 +
  198 + return locations.ToList();
  199 + }
  200 +
  201 + private static List<string>? ResolvePartnerContextForCollapse(IReadOnlyList<string> partners)
  202 + {
  203 + if (partners.Count == 0 || AllScopeBindingHelper.IsDeclaredAll(partners[0]))
  204 + {
  205 + return null;
  206 + }
  207 +
  208 + return partners.ToList();
  209 + }
  210 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberScopeDisplayHelper.cs
... ... @@ -26,7 +26,8 @@ public static class TeamMemberScopeDisplayHelper
26 26 Guid? roleId,
27 27 IReadOnlyList<string> partnerIds,
28 28 IReadOnlyList<string> regionIds,
29   - IReadOnlyList<TeamMemberAssignedLocationDto> assigned)
  29 + IReadOnlyList<TeamMemberAssignedLocationDto> assigned,
  30 + string? appliedLocationType = null)
30 31 {
31 32 var assignedIds = assigned
32 33 .Select(x => x.Id)
... ... @@ -35,30 +36,43 @@ public static class TeamMemberScopeDisplayHelper
35 36 .Distinct(StringComparer.Ordinal)
36 37 .ToList();
37 38  
  39 + if (AllScopeBindingHelper.IsDeclaredAll(appliedLocationType))
  40 + {
  41 + if (partnerIds.Count > 0)
  42 + {
  43 + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds);
  44 + var universe = concreteRegions.Count > 0
  45 + ? await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partnerIds, concreteRegions)
  46 + : await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerIds);
  47 + if (universe.Count > 0
  48 + && !AllScopeBindingHelper.IsFullIdSelection(assignedIds, universe))
  49 + {
  50 + return assigned.ToList();
  51 + }
  52 + }
  53 +
  54 + return AllLocationDisplay;
  55 + }
  56 +
38 57 if (assignedIds.Count == 0)
39 58 {
40 59 return assigned.ToList();
41 60 }
42 61  
43   - if (await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(db, roleId) && partnerIds.Count > 0)
  62 + if (partnerIds.Count > 0)
44 63 {
45   - var allPartnerLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(
46   - db, partnerIds);
47   - if (allPartnerLocationIds.Count > 0 &&
48   - AllScopeBindingHelper.IsFullIdSelection(assignedIds, allPartnerLocationIds))
  64 + // 有具体 Region 时按 Region 范围内全选判断;否则按 Company 全部门店
  65 + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regionIds);
  66 + var universe = concreteRegions.Count > 0
  67 + ? await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partnerIds, concreteRegions)
  68 + : await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partnerIds);
  69 + if (universe.Count > 0 &&
  70 + AllScopeBindingHelper.IsFullIdSelection(assignedIds, universe))
49 71 {
50 72 return AllLocationDisplay;
51 73 }
52 74 }
53 75  
54   - var universeLocationIds = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
55   - db, partnerIds, regionIds, null);
56   - if (universeLocationIds.Count > 0 &&
57   - AllScopeBindingHelper.IsFullIdSelection(assignedIds, universeLocationIds))
58   - {
59   - return AllLocationDisplay;
60   - }
61   -
62 76 return assigned.ToList();
63 77 }
64 78  
... ... @@ -69,8 +83,15 @@ public static class TeamMemberScopeDisplayHelper
69 83 ISqlSugarClient db,
70 84 IReadOnlyList<string> partnerIds,
71 85 IReadOnlyList<string> regionIds,
72   - IReadOnlyDictionary<string, string> regionNameMap)
  86 + IReadOnlyDictionary<string, string> regionNameMap,
  87 + string? appliedRegionType = null)
73 88 {
  89 + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType)
  90 + || (regionIds.Count == 1 && AllScopeBindingHelper.IsDeclaredAll(regionIds[0])))
  91 + {
  92 + return FoodLabelingDisplayConsts.AllRegion;
  93 + }
  94 +
74 95 if (regionIds.Count == 0)
75 96 {
76 97 return FoodLabelingDisplayConsts.NotAvailable;
... ... @@ -92,6 +113,118 @@ public static class TeamMemberScopeDisplayHelper
92 113 : id));
93 114 }
94 115  
  116 + /// <summary>
  117 + /// 编辑回显:库中存展开后的 Guid;结合 AppliedRegionType/AppliedLocationType 折叠为 <c>["ALL"]</c>。
  118 + /// </summary>
  119 + public static async Task<(List<string> RegionIds, List<string> LocationIds, List<TeamMemberAssignedLocationDto> Assigned)>
  120 + CollapseScopeIdsToAllSentinelForEditAsync(
  121 + ISqlSugarClient db,
  122 + IReadOnlyList<string> partnerIds,
  123 + IReadOnlyList<string> regionIds,
  124 + IReadOnlyList<string> locationIds,
  125 + IReadOnlyList<TeamMemberAssignedLocationDto> assigned,
  126 + string? appliedRegionType = null,
  127 + string? appliedLocationType = null)
  128 + {
  129 + var partners = LocationScopeBindingHelper.NormalizeIds(partnerIds);
  130 + var regions = LocationScopeBindingHelper.NormalizeIds(regionIds);
  131 + var locations = LocationScopeBindingHelper.NormalizeIds(locationIds);
  132 + var assignedList = assigned?.ToList() ?? new List<TeamMemberAssignedLocationDto>();
  133 +
  134 + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType))
  135 + {
  136 + regions = new List<string> { AllScopeBindingHelper.ScopeAll };
  137 + }
  138 +
  139 + if (AllScopeBindingHelper.IsDeclaredAll(appliedLocationType))
  140 + {
  141 + // 误标 ALL 但库中仅为部分门店时,回显具体 Id(与新增传参对称)
  142 + if (locations.Count > 0 && partners.Count > 0)
  143 + {
  144 + var scopedRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regions);
  145 + var universe = scopedRegions.Count > 0
  146 + ? await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partners, scopedRegions)
  147 + : await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners);
  148 + if (!AllScopeBindingHelper.IsFullIdSelection(locations, universe))
  149 + {
  150 + return (regions, locations, assignedList);
  151 + }
  152 + }
  153 +
  154 + locations = new List<string> { AllScopeBindingHelper.ScopeAll };
  155 + assignedList = AllLocationDisplay;
  156 + return (regions, locations, assignedList);
  157 + }
  158 +
  159 + // 落库为 SPECIFIED(显式选店):禁止再按「区内恰好全选」折叠成 ALL
  160 + if (AllScopeBindingHelper.IsDeclaredSpecified(appliedLocationType))
  161 + {
  162 + if (partners.Count == 0)
  163 + {
  164 + return (regions, locations, assignedList);
  165 + }
  166 +
  167 + if (!AllScopeBindingHelper.IsDeclaredAll(appliedRegionType))
  168 + {
  169 + var allRegionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(db, partners);
  170 + if (allRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(regions, allRegionIds))
  171 + {
  172 + regions = new List<string> { AllScopeBindingHelper.ScopeAll };
  173 + }
  174 + }
  175 +
  176 + return (regions, locations, assignedList);
  177 + }
  178 +
  179 + if (partners.Count == 0)
  180 + {
  181 + return (regions, locations, assignedList);
  182 + }
  183 +
  184 + if (!AllScopeBindingHelper.IsDeclaredAll(appliedRegionType))
  185 + {
  186 + var allRegionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(db, partners);
  187 + if (allRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(regions, allRegionIds))
  188 + {
  189 + regions = new List<string> { AllScopeBindingHelper.ScopeAll };
  190 + }
  191 + }
  192 +
  193 + // Region=ALL + 具体门店:禁止再按「派生单 Region 区内全选」把 Location 折成 ALL
  194 + if (AllScopeBindingHelper.IsDeclaredAll(appliedRegionType)
  195 + || (regions.Count == 1 && AllScopeBindingHelper.IsDeclaredAll(regions[0])))
  196 + {
  197 + var partnerUniverse = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(
  198 + db, partners);
  199 + if (partnerUniverse.Count > 1
  200 + && locations.Count > 1
  201 + && AllScopeBindingHelper.IsFullIdSelection(locations, partnerUniverse))
  202 + {
  203 + locations = new List<string> { AllScopeBindingHelper.ScopeAll };
  204 + assignedList = AllLocationDisplay;
  205 + }
  206 +
  207 + return (regions, locations, assignedList);
  208 + }
  209 +
  210 + // 具体 Region:按区内门店全集判断是否折叠 locationIds
  211 + var concreteRegions = LocationScopeBindingHelper.FilterConcreteScopeIds(regions);
  212 + var allLocationIds = concreteRegions.Count > 0
  213 + ? await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, partners, concreteRegions)
  214 + : await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners);
  215 + if (allLocationIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(locations, allLocationIds))
  216 + {
  217 + var hasConcreteRegions = concreteRegions.Count > 0;
  218 + if (hasConcreteRegions || locations.Count > 1 || allLocationIds.Count > 1)
  219 + {
  220 + locations = new List<string> { AllScopeBindingHelper.ScopeAll };
  221 + assignedList = AllLocationDisplay;
  222 + }
  223 + }
  224 +
  225 + return (regions, locations, assignedList);
  226 + }
  227 +
95 228 public static string FormatLocationTextForList(IReadOnlyList<TeamMemberAssignedLocationDto> assigned)
96 229 {
97 230 if (assigned.Count == 0)
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantBusinessContextHelper.cs 0 → 100644
  1 +using System.IdentityModel.Tokens.Jwt;
  2 +using System.Security.Claims;
  3 +using Microsoft.AspNetCore.Http;
  4 +using Volo.Abp.MultiTenancy;
  5 +using Volo.Abp.Security.Claims;
  6 +using Yi.Framework.Rbac.Domain.Shared.Consts;
  7 +using Yi.Framework.SqlSugarCore.Abstractions;
  8 +
  9 +namespace FoodLabeling.Application.Helpers;
  10 +
  11 +/// <summary>
  12 +/// SaaS 业务租户上下文判定(平台主库 / Default 租户不走公司菜单子集过滤)。
  13 +/// </summary>
  14 +public static class TenantBusinessContextHelper
  15 +{
  16 + private static readonly Guid ProtectedDefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
  17 +
  18 + /// <summary>
  19 + /// 解析当前请求的业务租户 Id:优先 <see cref="ICurrentTenant"/>,其次 JWT / Authorization Claim。
  20 + /// </summary>
  21 + public static Guid? ResolveBusinessTenantId(ICurrentTenant? currentTenant, HttpContext? httpContext)
  22 + {
  23 + if (currentTenant?.Id is { } fromContext
  24 + && fromContext != Guid.Empty
  25 + && fromContext != ProtectedDefaultTenantId)
  26 + {
  27 + return fromContext;
  28 + }
  29 +
  30 + var fromJwt = TryGetTenantIdFromHttpContext(httpContext);
  31 + if (fromJwt.HasValue
  32 + && fromJwt.Value != Guid.Empty
  33 + && fromJwt.Value != ProtectedDefaultTenantId)
  34 + {
  35 + return fromJwt;
  36 + }
  37 +
  38 + return null;
  39 + }
  40 +
  41 + /// <summary>
  42 + /// 当前请求是否处于真实公司业务租户上下文(须按 fl_th_tenant_menu_permission 限定可分配菜单)。
  43 + /// </summary>
  44 + public static bool ShouldScopeMenusByCompany(
  45 + DbConnOptions options,
  46 + ICurrentTenant? currentTenant,
  47 + HttpContext? httpContext)
  48 + {
  49 + return ShouldScopeMenusByCompany(options, ResolveBusinessTenantId(currentTenant, httpContext));
  50 + }
  51 +
  52 + /// <summary>
  53 + /// 当前请求是否处于真实公司业务租户上下文(须按 fl_th_tenant_menu_permission 限定可分配菜单)。
  54 + /// </summary>
  55 + public static bool ShouldScopeMenusByCompany(DbConnOptions options, Guid? tenantId)
  56 + {
  57 + return options.EnabledSaasMultiTenancy
  58 + && tenantId.HasValue
  59 + && tenantId.Value != Guid.Empty
  60 + && tenantId.Value != ProtectedDefaultTenantId;
  61 + }
  62 +
  63 + /// <summary>
  64 + /// 从 HttpContext Principal 或 Authorization Bearer JWT 读取 TenantId Claim 原始值。
  65 + /// </summary>
  66 + public static string? TryGetTenantIdClaimValue(HttpContext? httpContext)
  67 + {
  68 + if (httpContext is null)
  69 + {
  70 + return null;
  71 + }
  72 +
  73 + return TryGetTenantIdFromPrincipal(httpContext.User)
  74 + ?? TryGetTenantIdFromAuthorizationHeader(httpContext.Request.Headers.Authorization.ToString());
  75 + }
  76 +
  77 + private static Guid? TryGetTenantIdFromHttpContext(HttpContext? httpContext)
  78 + {
  79 + var tenantClaim = TryGetTenantIdClaimValue(httpContext);
  80 + if (string.IsNullOrWhiteSpace(tenantClaim)
  81 + || !Guid.TryParse(tenantClaim, out var tenantGuid)
  82 + || tenantGuid == Guid.Empty)
  83 + {
  84 + return null;
  85 + }
  86 +
  87 + return tenantGuid;
  88 + }
  89 +
  90 + private static string? TryGetTenantIdFromPrincipal(ClaimsPrincipal? user)
  91 + {
  92 + if (user?.Identity?.IsAuthenticated != true)
  93 + {
  94 + return null;
  95 + }
  96 +
  97 + return user.FindFirst(TokenTypeConst.TenantId)?.Value
  98 + ?? user.FindFirst(AbpClaimTypes.TenantId)?.Value
  99 + ?? user.Claims.FirstOrDefault(c =>
  100 + c.Type.EndsWith("tenantId", StringComparison.OrdinalIgnoreCase)
  101 + || c.Type.Equals("tenantid", StringComparison.OrdinalIgnoreCase))?.Value;
  102 + }
  103 +
  104 + /// <summary>
  105 + /// 多租户中间件可能早于 JWT Principal 就绪;直接从 Authorization 解析 TenantId Claim。
  106 + /// </summary>
  107 + private static string? TryGetTenantIdFromAuthorizationHeader(string? authorization)
  108 + {
  109 + if (string.IsNullOrWhiteSpace(authorization))
  110 + {
  111 + return null;
  112 + }
  113 +
  114 + const string bearerPrefix = "Bearer ";
  115 + if (!authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
  116 + {
  117 + return null;
  118 + }
  119 +
  120 + var jwt = authorization[bearerPrefix.Length..].Trim();
  121 + if (string.IsNullOrWhiteSpace(jwt))
  122 + {
  123 + return null;
  124 + }
  125 +
  126 + try
  127 + {
  128 + var token = new JwtSecurityTokenHandler().ReadJwtToken(jwt);
  129 + return token.Claims.FirstOrDefault(c =>
  130 + c.Type == TokenTypeConst.TenantId
  131 + || c.Type == AbpClaimTypes.TenantId
  132 + || c.Type.EndsWith("tenantId", StringComparison.OrdinalIgnoreCase))
  133 + ?.Value;
  134 + }
  135 + catch (Exception)
  136 + {
  137 + return null;
  138 + }
  139 + }
  140 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs
1 1 using Volo.Abp;
2 2 using Volo.Abp.MultiTenancy;
  3 +using Yi.Framework.SqlSugarCore.Abstractions;
3 4  
4 5 namespace FoodLabeling.Application.Helpers;
5 6  
... ... @@ -8,9 +9,15 @@ namespace FoodLabeling.Application.Helpers;
8 9 /// </summary>
9 10 public static class TenantContextGuard
10 11 {
  12 + /// <summary>
  13 + /// 平台主库登录(JWT/__tenant 均无业务租户)时访问 fl_* 等业务表的友好提示。
  14 + /// </summary>
  15 + public const string PlatformCannotAccessBusinessDataMessage =
  16 + "当前为平台主库登录,无法访问公司业务数据(标签/产品/成员等)。请选择具体公司登录,或使用平台「公司管理」相关接口。";
  17 +
11 18 public static void EnsureTenantResolved(ICurrentTenant currentTenant, string? operation = null)
12 19 {
13   - if (currentTenant.Id.HasValue)
  20 + if (currentTenant.Id.HasValue && currentTenant.Id.Value != Guid.Empty)
14 21 {
15 22 return;
16 23 }
... ... @@ -19,6 +26,78 @@ public static class TenantContextGuard
19 26 ? "未识别租户上下文"
20 27 : $"{operation}:未识别租户上下文";
21 28 throw new UserFriendlyException(
22   - $"{hint}。请使用泰额登录接口(th-web-auth / th-app-auth)或请求头 __tenant 携带租户 Id。");
  29 + $"{hint}。请使用泰额登录接口(th-web-auth / th-app-auth)选择具体公司登录,或请求头 __tenant 携带租户 Id。");
  30 + }
  31 +
  32 + /// <summary>
  33 + /// 泰额 SaaS 多租户开启时,业务表(fl_* / location 等)必须走租户库,禁止落到 host。
  34 + /// </summary>
  35 + public static void EnsureBusinessTenantIfSaas(
  36 + ICurrentTenant currentTenant,
  37 + DbConnOptions? dbConnOptions,
  38 + string? operation = null)
  39 + {
  40 + if (dbConnOptions is null || !dbConnOptions.EnabledSaasMultiTenancy)
  41 + {
  42 + return;
  43 + }
  44 +
  45 + EnsureTenantResolved(currentTenant, operation);
  46 + }
  47 +
  48 + /// <summary>
  49 + /// 判断当前 DbContext 是否连到平台主库(antis-foodlabeling-host)。
  50 + /// </summary>
  51 + public static bool IsConnectedToHostDatabase(ISqlSugarDbContext dbContext, DbConnOptions dbConnOptions)
  52 + {
  53 + var dbName = dbContext.SqlSugarClient.Ado.Connection.Database;
  54 + var hostDbName = TryExtractDatabaseName(dbConnOptions.Url);
  55 + return !string.IsNullOrWhiteSpace(hostDbName)
  56 + && string.Equals(dbName, hostDbName, StringComparison.OrdinalIgnoreCase);
  57 + }
  58 +
  59 + /// <summary>
  60 + /// SaaS 模式下校验 DbContext 未落到 host 主库(双保险,避免缺表 500)。
  61 + /// </summary>
  62 + public static void EnsureNotHostDatabaseIfSaas(
  63 + ISqlSugarDbContext dbContext,
  64 + DbConnOptions dbConnOptions,
  65 + string? operation = null)
  66 + {
  67 + if (!dbConnOptions.EnabledSaasMultiTenancy)
  68 + {
  69 + return;
  70 + }
  71 +
  72 + if (!IsConnectedToHostDatabase(dbContext, dbConnOptions))
  73 + {
  74 + return;
  75 + }
  76 +
  77 + var hint = string.IsNullOrWhiteSpace(operation)
  78 + ? PlatformCannotAccessBusinessDataMessage
  79 + : $"{operation}:{PlatformCannotAccessBusinessDataMessage}";
  80 + throw new UserFriendlyException(hint);
  81 + }
  82 +
  83 + private static string? TryExtractDatabaseName(string? connectionString)
  84 + {
  85 + if (string.IsNullOrWhiteSpace(connectionString))
  86 + {
  87 + return null;
  88 + }
  89 +
  90 + foreach (var part in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries))
  91 + {
  92 + var kv = part.Split('=', 2, StringSplitOptions.TrimEntries);
  93 + if (kv.Length == 2
  94 + && (kv[0].Equals("database", StringComparison.OrdinalIgnoreCase)
  95 + || kv[0].Equals("Database", StringComparison.OrdinalIgnoreCase)))
  96 + {
  97 + return kv[1].Trim();
  98 + }
  99 + }
  100 +
  101 + return null;
23 102 }
24 103 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TrainingFileScopeHelper.cs 0 → 100644
  1 +using FoodLabeling.Application.Services.DbModels;
  2 +using FoodLabeling.Domain.Shared.Helpers;
  3 +using SqlSugar;
  4 +using Volo.Abp;
  5 +
  6 +namespace FoodLabeling.Application.Helpers;
  7 +
  8 +/// <summary>
  9 +/// 培训文件适用范围:Company / Region / Location 三维度独立 ALL/SPECIFIED。
  10 +/// </summary>
  11 +public static class TrainingFileScopeHelper
  12 +{
  13 + public sealed class TrainingFileScopeSaveResult
  14 + {
  15 + public string AppliedPartnerType { get; init; } = AllScopeBindingHelper.ScopeAll;
  16 +
  17 + public List<string> PartnerIds { get; init; } = new();
  18 +
  19 + public string AppliedRegionType { get; init; } = AllScopeBindingHelper.ScopeAll;
  20 +
  21 + public List<string> RegionIds { get; init; } = new();
  22 +
  23 + public string AvailabilityType { get; init; } = AllScopeBindingHelper.ScopeAll;
  24 +
  25 + public List<string> LocationIds { get; init; } = new();
  26 + }
  27 +
  28 + public sealed class TrainingFileScopeDisplay
  29 + {
  30 + public string AppliedPartnerType { get; init; } = AllScopeBindingHelper.ScopeAll;
  31 +
  32 + public string Company { get; init; } = LabelEntityPartnerScopeHelper.AllCompaniesDisplay;
  33 +
  34 + public List<string> PartnerIds { get; init; } = new();
  35 +
  36 + public string AppliedRegionType { get; init; } = AllScopeBindingHelper.ScopeAll;
  37 +
  38 + public string Region { get; init; } = FoodLabelingDisplayConsts.AllRegion;
  39 +
  40 + public List<string> RegionIds { get; init; } = new();
  41 +
  42 + public string AvailabilityType { get; init; } = AllScopeBindingHelper.ScopeAll;
  43 +
  44 + public string Location { get; init; } = FoodLabelingDisplayConsts.AllLocation;
  45 +
  46 + public List<string> LocationIds { get; init; } = new();
  47 + }
  48 +
  49 + public sealed class LocationScopeContext
  50 + {
  51 + public string LocationId { get; init; } = string.Empty;
  52 +
  53 + public string? PartnerId { get; init; }
  54 +
  55 + public string? GroupId { get; init; }
  56 + }
  57 +
  58 + /// <summary>
  59 + /// 解析保存入参中的三维度范围。
  60 + /// </summary>
  61 + public static async Task<TrainingFileScopeSaveResult> ResolveScopeForSaveAsync(
  62 + ISqlSugarClient db,
  63 + string? appliedPartnerType,
  64 + IReadOnlyList<string>? partnerIds,
  65 + IReadOnlyList<string>? companyIds,
  66 + string? appliedRegionType,
  67 + IReadOnlyList<string>? regionIds,
  68 + IReadOnlyList<string>? groupIds,
  69 + string? availabilityType,
  70 + IReadOnlyList<string>? locationIds)
  71 + {
  72 + var partnerScope = await LabelEntityPartnerScopeHelper.ResolvePartnerScopeForSaveAsync(
  73 + db,
  74 + appliedPartnerType,
  75 + partnerIds,
  76 + companyIds);
  77 +
  78 + var partnerContext = string.Equals(
  79 + partnerScope.AppliedPartnerType,
  80 + AllScopeBindingHelper.ScopeSpecified,
  81 + StringComparison.OrdinalIgnoreCase)
  82 + ? partnerScope.PartnerIds
  83 + : null;
  84 +
  85 + var mergedRegionIds = NormalizeRegionIds(regionIds, groupIds);
  86 + var hasRegionArray = regionIds is not null || groupIds is not null;
  87 + var (regionType, regionIdsForSave) = await AllScopeBindingHelper.NormalizeRegionScopeAsync(
  88 + db,
  89 + appliedRegionType,
  90 + mergedRegionIds,
  91 + hasRegionArray,
  92 + partnerContext);
  93 +
  94 + if (string.Equals(regionType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase)
  95 + && regionIdsForSave.Count > 0)
  96 + {
  97 + await ValidateGroupIdsExistAsync(db, regionIdsForSave);
  98 + }
  99 +
  100 + var hasLocationArray = locationIds is not null;
  101 + var (locationType, locationIdsForSave) = await AllScopeBindingHelper.NormalizeLocationScopeAsync(
  102 + db,
  103 + availabilityType,
  104 + locationIds ?? new List<string>(),
  105 + hasLocationArray,
  106 + partnerContext,
  107 + regionIdsForSave);
  108 +
  109 + if (string.Equals(locationType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase)
  110 + && locationIdsForSave.Count > 0)
  111 + {
  112 + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, locationIdsForSave);
  113 + }
  114 +
  115 + return new TrainingFileScopeSaveResult
  116 + {
  117 + AppliedPartnerType = partnerScope.AppliedPartnerType,
  118 + PartnerIds = partnerScope.PartnerIds,
  119 + AppliedRegionType = regionType,
  120 + RegionIds = regionIdsForSave,
  121 + AvailabilityType = locationType,
  122 + LocationIds = locationIdsForSave
  123 + };
  124 + }
  125 +
  126 + /// <summary>
  127 + /// 保存培训文件三维度范围关联。
  128 + /// </summary>
  129 + public static async Task SaveScopeAsync(
  130 + ISqlSugarClient db,
  131 + string trainingFileId,
  132 + TrainingFileScopeSaveResult scope,
  133 + string? currentUserId,
  134 + DateTime now)
  135 + {
  136 + await db.Deleteable<FlTrainingFilePartnerDbEntity>()
  137 + .Where(x => x.TrainingFileId == trainingFileId)
  138 + .ExecuteCommandAsync();
  139 + await db.Deleteable<FlTrainingFileRegionDbEntity>()
  140 + .Where(x => x.TrainingFileId == trainingFileId)
  141 + .ExecuteCommandAsync();
  142 + await db.Deleteable<FlTrainingFileLocationDbEntity>()
  143 + .Where(x => x.TrainingFileId == trainingFileId)
  144 + .ExecuteCommandAsync();
  145 +
  146 + if (string.Equals(scope.AppliedPartnerType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase)
  147 + && scope.PartnerIds.Count > 0)
  148 + {
  149 + var rows = scope.PartnerIds.Select(pid => new FlTrainingFilePartnerDbEntity
  150 + {
  151 + Id = YitIdHelper.NextId().ToString(),
  152 + TrainingFileId = trainingFileId,
  153 + PartnerId = pid,
  154 + CreationTime = now,
  155 + CreatorId = currentUserId
  156 + }).ToList();
  157 + await db.Insertable(rows).ExecuteCommandAsync();
  158 + }
  159 +
  160 + if (string.Equals(scope.AppliedRegionType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase)
  161 + && scope.RegionIds.Count > 0)
  162 + {
  163 + var rows = scope.RegionIds.Select(gid => new FlTrainingFileRegionDbEntity
  164 + {
  165 + Id = YitIdHelper.NextId().ToString(),
  166 + TrainingFileId = trainingFileId,
  167 + GroupId = gid,
  168 + CreationTime = now,
  169 + CreatorId = currentUserId
  170 + }).ToList();
  171 + await db.Insertable(rows).ExecuteCommandAsync();
  172 + }
  173 +
  174 + if (string.Equals(scope.AvailabilityType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase)
  175 + && scope.LocationIds.Count > 0)
  176 + {
  177 + var rows = scope.LocationIds.Select(lid => new FlTrainingFileLocationDbEntity
  178 + {
  179 + Id = YitIdHelper.NextId().ToString(),
  180 + TrainingFileId = trainingFileId,
  181 + LocationId = lid,
  182 + CreationTime = now,
  183 + CreatorId = currentUserId
  184 + }).ToList();
  185 + await db.Insertable(rows).ExecuteCommandAsync();
  186 + }
  187 + }
  188 +
  189 + /// <summary>
  190 + /// 构建多个培训文件的适用范围回显(列表/树批量用)。
  191 + /// </summary>
  192 + public static async Task<Dictionary<string, TrainingFileScopeDisplay>> BuildScopeDisplayMapAsync(
  193 + ISqlSugarClient db,
  194 + IReadOnlyList<FlTrainingFileDbEntity> files)
  195 + {
  196 + if (files.Count == 0)
  197 + {
  198 + return new Dictionary<string, TrainingFileScopeDisplay>(StringComparer.Ordinal);
  199 + }
  200 +
  201 + var tasks = files.Select(async file =>
  202 + (file.Id, Display: await BuildScopeDisplayAsync(db, file)));
  203 + var results = await Task.WhenAll(tasks);
  204 + return results.ToDictionary(x => x.Id, x => x.Display, StringComparer.Ordinal);
  205 + }
  206 +
  207 + /// <summary>
  208 + /// 删除培训文件全部范围关联。
  209 + /// </summary>
  210 + public static async Task DeleteScopeRowsAsync(ISqlSugarClient db, string trainingFileId)
  211 + {
  212 + await db.Deleteable<FlTrainingFilePartnerDbEntity>()
  213 + .Where(x => x.TrainingFileId == trainingFileId)
  214 + .ExecuteCommandAsync();
  215 + await db.Deleteable<FlTrainingFileRegionDbEntity>()
  216 + .Where(x => x.TrainingFileId == trainingFileId)
  217 + .ExecuteCommandAsync();
  218 + await db.Deleteable<FlTrainingFileLocationDbEntity>()
  219 + .Where(x => x.TrainingFileId == trainingFileId)
  220 + .ExecuteCommandAsync();
  221 + }
  222 +
  223 + /// <summary>
  224 + /// 构建详情/编辑回显用的范围数据。
  225 + /// </summary>
  226 + public static async Task<TrainingFileScopeDisplay> BuildScopeDisplayAsync(
  227 + ISqlSugarClient db,
  228 + FlTrainingFileDbEntity file)
  229 + {
  230 + var partnerIds = string.Equals(
  231 + file.AppliedPartnerType,
  232 + AllScopeBindingHelper.ScopeSpecified,
  233 + StringComparison.OrdinalIgnoreCase)
  234 + ? await db.Queryable<FlTrainingFilePartnerDbEntity>()
  235 + .Where(x => x.TrainingFileId == file.Id)
  236 + .Select(x => x.PartnerId)
  237 + .ToListAsync()
  238 + : new List<string>();
  239 +
  240 + var regionIds = string.Equals(
  241 + file.AppliedRegionType,
  242 + AllScopeBindingHelper.ScopeSpecified,
  243 + StringComparison.OrdinalIgnoreCase)
  244 + ? await db.Queryable<FlTrainingFileRegionDbEntity>()
  245 + .Where(x => x.TrainingFileId == file.Id)
  246 + .Select(x => x.GroupId)
  247 + .ToListAsync()
  248 + : new List<string>();
  249 +
  250 + var locationIds = string.Equals(
  251 + file.AvailabilityType,
  252 + AllScopeBindingHelper.ScopeSpecified,
  253 + StringComparison.OrdinalIgnoreCase)
  254 + ? await db.Queryable<FlTrainingFileLocationDbEntity>()
  255 + .Where(x => x.TrainingFileId == file.Id)
  256 + .Select(x => x.LocationId)
  257 + .ToListAsync()
  258 + : new List<string>();
  259 +
  260 + partnerIds = LocationScopeBindingHelper.NormalizeIds(partnerIds);
  261 + regionIds = LocationScopeBindingHelper.NormalizeIds(regionIds);
  262 + locationIds = LocationScopeBindingHelper.NormalizeIds(locationIds);
  263 +
  264 + var collapsed = await ScopeAllEchoHelper.CollapseScopeIdsToAllSentinelAsync(
  265 + db,
  266 + partnerIds,
  267 + regionIds,
  268 + locationIds,
  269 + ScopeAllEchoHelper.ForLabelTemplateDimensions(
  270 + file.AppliedPartnerType,
  271 + file.AppliedRegionType,
  272 + file.AvailabilityType));
  273 +
  274 + return new TrainingFileScopeDisplay
  275 + {
  276 + AppliedPartnerType = file.AppliedPartnerType,
  277 + Company = await BuildPartnerDisplayAsync(db, file.AppliedPartnerType, collapsed.PartnerIds),
  278 + PartnerIds = collapsed.PartnerIds,
  279 + AppliedRegionType = file.AppliedRegionType,
  280 + Region = await BuildRegionDisplayAsync(db, file.AppliedRegionType, collapsed.RegionIds),
  281 + RegionIds = collapsed.RegionIds,
  282 + AvailabilityType = file.AvailabilityType,
  283 + Location = await BuildLocationDisplayAsync(db, file.AvailabilityType, collapsed.LocationIds),
  284 + LocationIds = collapsed.LocationIds
  285 + };
  286 + }
  287 +
  288 + /// <summary>
  289 + /// 按门店上下文过滤可见文件(Company + Region + Location 三维度 AND)。
  290 + /// </summary>
  291 + public static ISugarQueryable<FlTrainingFileDbEntity> ApplyLocationVisibilityFilter(
  292 + ISugarQueryable<FlTrainingFileDbEntity> query,
  293 + LocationScopeContext? context)
  294 + {
  295 + if (context is null || string.IsNullOrWhiteSpace(context.LocationId))
  296 + {
  297 + return query;
  298 + }
  299 +
  300 + var locationId = context.LocationId.Trim();
  301 + var partnerId = context.PartnerId?.Trim();
  302 + var groupId = context.GroupId?.Trim();
  303 +
  304 + return query.Where(f =>
  305 + f.AppliedPartnerType == AllScopeBindingHelper.ScopeAll
  306 + || (partnerId != null
  307 + && SqlFunc.Subqueryable<FlTrainingFilePartnerDbEntity>()
  308 + .Where(p => p.TrainingFileId == f.Id && p.PartnerId == partnerId)
  309 + .Any()))
  310 + .Where(f =>
  311 + f.AppliedRegionType == AllScopeBindingHelper.ScopeAll
  312 + || (groupId != null
  313 + && SqlFunc.Subqueryable<FlTrainingFileRegionDbEntity>()
  314 + .Where(r => r.TrainingFileId == f.Id && r.GroupId == groupId)
  315 + .Any()))
  316 + .Where(f =>
  317 + f.AvailabilityType == AllScopeBindingHelper.ScopeAll
  318 + || SqlFunc.Subqueryable<FlTrainingFileLocationDbEntity>()
  319 + .Where(l => l.TrainingFileId == f.Id && l.LocationId == locationId)
  320 + .Any());
  321 + }
  322 +
  323 + /// <summary>
  324 + /// 解析门店对应的 Company / Region 上下文。
  325 + /// </summary>
  326 + public static async Task<LocationScopeContext?> ResolveLocationScopeContextAsync(
  327 + ISqlSugarClient db,
  328 + string locationId)
  329 + {
  330 + if (string.IsNullOrWhiteSpace(locationId))
  331 + {
  332 + return null;
  333 + }
  334 +
  335 + var normalized = locationId.Trim();
  336 + var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, new[] { normalized });
  337 + var groupIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(db, new[] { normalized });
  338 +
  339 + return new LocationScopeContext
  340 + {
  341 + LocationId = normalized,
  342 + PartnerId = partnerIds.FirstOrDefault(),
  343 + GroupId = groupIds.FirstOrDefault()
  344 + };
  345 + }
  346 +
  347 + private static List<string> NormalizeRegionIds(
  348 + IReadOnlyList<string>? regionIds,
  349 + IReadOnlyList<string>? groupIds)
  350 + {
  351 + var merged = new HashSet<string>(StringComparer.Ordinal);
  352 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(regionIds))
  353 + {
  354 + merged.Add(id);
  355 + }
  356 +
  357 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(groupIds))
  358 + {
  359 + merged.Add(id);
  360 + }
  361 +
  362 + return merged.OrderBy(x => x, StringComparer.Ordinal).ToList();
  363 + }
  364 +
  365 + private static async Task ValidateGroupIdsExistAsync(ISqlSugarClient db, List<string> groupIds)
  366 + {
  367 + if (groupIds.Count == 0)
  368 + {
  369 + return;
  370 + }
  371 +
  372 + var existing = await db.Queryable<FlGroupDbEntity>()
  373 + .Where(x => !x.IsDeleted && groupIds.Contains(x.Id))
  374 + .Select(x => x.Id)
  375 + .ToListAsync();
  376 + var existingSet = new HashSet<string>(existing, StringComparer.OrdinalIgnoreCase);
  377 + var missing = groupIds.Where(id => !existingSet.Contains(id)).ToList();
  378 + if (missing.Count > 0)
  379 + {
  380 + throw new UserFriendlyException("存在无效的 Region Id");
  381 + }
  382 + }
  383 +
  384 + private static async Task<string> BuildPartnerDisplayAsync(
  385 + ISqlSugarClient db,
  386 + string appliedPartnerType,
  387 + IReadOnlyList<string> partnerIds)
  388 + {
  389 + if (!string.Equals(appliedPartnerType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase))
  390 + {
  391 + return LabelEntityPartnerScopeHelper.AllCompaniesDisplay;
  392 + }
  393 +
  394 + if (LocationScopeBindingHelper.ContainsAllScopeSentinel(partnerIds))
  395 + {
  396 + return LabelEntityPartnerScopeHelper.AllCompaniesDisplay;
  397 + }
  398 +
  399 + if (partnerIds.Count == 0)
  400 + {
  401 + return FoodLabelingDisplayConsts.NotAvailable;
  402 + }
  403 +
  404 + var rows = await db.Queryable<FlPartnerDbEntity>()
  405 + .Where(x => !x.IsDeleted && partnerIds.Contains(x.Id))
  406 + .Select(x => x.PartnerName)
  407 + .ToListAsync();
  408 + var names = rows.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()).Distinct().OrderBy(x => x).ToList();
  409 + return names.Count > 0 ? string.Join(", ", names) : FoodLabelingDisplayConsts.NotAvailable;
  410 + }
  411 +
  412 + private static async Task<string> BuildRegionDisplayAsync(
  413 + ISqlSugarClient db,
  414 + string appliedRegionType,
  415 + IReadOnlyList<string> regionIds)
  416 + {
  417 + if (!string.Equals(appliedRegionType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase))
  418 + {
  419 + return FoodLabelingDisplayConsts.AllRegion;
  420 + }
  421 +
  422 + if (LocationScopeBindingHelper.ContainsAllScopeSentinel(regionIds) || regionIds.Count == 0)
  423 + {
  424 + return regionIds.Count == 0
  425 + ? FoodLabelingDisplayConsts.NotAvailable
  426 + : FoodLabelingDisplayConsts.AllRegion;
  427 + }
  428 +
  429 + var rows = await db.Queryable<FlGroupDbEntity>()
  430 + .Where(x => !x.IsDeleted && regionIds.Contains(x.Id))
  431 + .Select(x => x.GroupName)
  432 + .ToListAsync();
  433 + var names = rows.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()).Distinct().OrderBy(x => x).ToList();
  434 + return names.Count > 0 ? string.Join(", ", names) : FoodLabelingDisplayConsts.NotAvailable;
  435 + }
  436 +
  437 + private static async Task<string> BuildLocationDisplayAsync(
  438 + ISqlSugarClient db,
  439 + string availabilityType,
  440 + IReadOnlyList<string> locationIds)
  441 + {
  442 + if (!string.Equals(availabilityType, AllScopeBindingHelper.ScopeSpecified, StringComparison.OrdinalIgnoreCase))
  443 + {
  444 + return FoodLabelingDisplayConsts.AllLocation;
  445 + }
  446 +
  447 + if (LocationScopeBindingHelper.ContainsAllScopeSentinel(locationIds) || locationIds.Count == 0)
  448 + {
  449 + return locationIds.Count == 0
  450 + ? FoodLabelingDisplayConsts.NotAvailable
  451 + : FoodLabelingDisplayConsts.AllLocation;
  452 + }
  453 +
  454 + var guidList = locationIds.Where(x => Guid.TryParse(x, out _)).Select(Guid.Parse).ToList();
  455 + if (guidList.Count == 0)
  456 + {
  457 + return FoodLabelingDisplayConsts.NotAvailable;
  458 + }
  459 +
  460 + var rows = await db.Queryable<FoodLabeling.Domain.Entities.LocationAggregateRoot>()
  461 + .Where(x => !x.IsDeleted && guidList.Contains(x.Id))
  462 + .Select(x => x.LocationName)
  463 + .ToListAsync();
  464 + var names = rows.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()).Distinct().OrderBy(x => x).ToList();
  465 + return names.Count > 0 ? string.Join(", ", names) : FoodLabelingDisplayConsts.NotAvailable;
  466 + }
  467 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/UsAppAuthScopeHelper.cs
... ... @@ -27,7 +27,10 @@ public static class UsAppAuthScopeHelper
27 27 }
28 28  
29 29 var kind = currentUser.FindClaim(UsAppJwtClaims.ClientKind)?.Value;
30   - if (!string.Equals(kind, UsAppJwtClaims.ClientKindUsApp, StringComparison.Ordinal))
  30 + // 美国版 us-app;泰额版 us-app-auth 转发 th-app-auth 后签发 th_app
  31 + var isAppToken = string.Equals(kind, UsAppJwtClaims.ClientKindUsApp, StringComparison.Ordinal)
  32 + || string.Equals(kind, UsAppJwtClaims.ClientKindThApp, StringComparison.Ordinal);
  33 + if (!isAppToken)
31 34 {
32 35 throw new UserFriendlyException("请使用 App 登录令牌调用该接口");
33 36 }
... ... @@ -53,7 +56,13 @@ public static class UsAppAuthScopeHelper
53 56 return true;
54 57 }
55 58  
56   - return await TeamMemberRoleHelper.IsCompanyAdminUserAsync(db, currentUser);
  59 + if (await TeamMemberRoleHelper.IsCompanyAdminUserAsync(db, currentUser))
  60 + {
  61 + return true;
  62 + }
  63 +
  64 + // App JWT 可能未写 role claim:回退查库 RoleCode=admin
  65 + return await ReportsRoleHelper.IsAdminRoleFromDbAsync(db, currentUser.Id.Value);
57 66 }
58 67  
59 68 public static async Task<List<AuthScopeCompanyOptionDto>> ListCompaniesAsync(ISqlSugarClient db)
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/EmbeddedTenantSqlScripts.cs 0 → 100644
  1 +using System.Reflection;
  2 +
  3 +namespace FoodLabeling.Application.MultiTenancy;
  4 +
  5 +/// <summary>
  6 +/// 从程序集嵌入资源读取租户初始化 SQL 脚本。
  7 +/// </summary>
  8 +public static class EmbeddedTenantSqlScripts
  9 +{
  10 + /// <summary>
  11 + /// 读取指定嵌入资源中的 SQL 文本。
  12 + /// </summary>
  13 + /// <param name="assembly">包含 EmbeddedResource 的程序集</param>
  14 + /// <param name="resourceName">LogicalName 或完整资源名</param>
  15 + public static string Read(Assembly assembly, string resourceName)
  16 + {
  17 + var stream = assembly.GetManifestResourceStream(resourceName);
  18 + if (stream == null)
  19 + {
  20 + var available = string.Join(", ", assembly.GetManifestResourceNames());
  21 + throw new InvalidOperationException(
  22 + $"未找到嵌入 SQL 资源「{resourceName}」。可用资源:{available}");
  23 + }
  24 +
  25 + using (stream)
  26 + using (var reader = new StreamReader(stream))
  27 + {
  28 + return reader.ReadToEnd();
  29 + }
  30 + }
  31 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/FoodLabelingTenantMigrationScriptNames.cs 0 → 100644
  1 +namespace FoodLabeling.Application.MultiTenancy;
  2 +
  3 +/// <summary>
  4 +/// 泰额版新租户业务库初始化时自动执行的嵌入 SQL 资源名(顺序即执行顺序)。
  5 +/// </summary>
  6 +public static class FoodLabelingTenantMigrationScriptNames
  7 +{
  8 + public const string AppliedRegionType = "FoodLabeling.TenantMigrations.fl_entity_applied_region_type.sql";
  9 +
  10 + public const string LabelPartnerId = "FoodLabeling.TenantMigrations.fl_label_partner_id.sql";
  11 +
  12 + public const string ProductCategoryPartnerScope =
  13 + "FoodLabeling.TenantMigrations.fl_product_category_partner_scope.sql";
  14 +
  15 + public const string UserLocation = "FoodLabeling.TenantMigrations.fl_userlocation.sql";
  16 +
  17 + public const string TeamMemberScope = "FoodLabeling.TenantMigrations.fl_team_member_scope.sql";
  18 +
  19 + public const string Training = "FoodLabeling.TenantMigrations.fl_training.sql";
  20 +
  21 + public const string LabelAlertTimer = "FoodLabeling.TenantMigrations.fl_label_alert_timer.sql";
  22 +
  23 + /// <summary>CodeFirst 之后按序执行的脚本资源名。</summary>
  24 + public static readonly IReadOnlyList<string> TenantInitializationOrder =
  25 + [
  26 + AppliedRegionType,
  27 + LabelPartnerId,
  28 + ProductCategoryPartnerScope,
  29 + UserLocation,
  30 + TeamMemberScope,
  31 + Training,
  32 + LabelAlertTimer
  33 + ];
  34 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs
  1 +using FoodLabeling.Application.Helpers;
1 2 using Microsoft.AspNetCore.Http;
2 3 using Volo.Abp.MultiTenancy;
3   -using Volo.Abp.Security.Claims;
4   -using Yi.Framework.Rbac.Domain.Shared.Consts;
5 4  
6 5 namespace FoodLabeling.Application.MultiTenancy;
7 6  
8 7 /// <summary>
9   -/// 从 JWT Claim <see cref="TokenTypeConst.TenantId"/> 解析当前租户(泰额版登录写入)
  8 +/// 从 JWT Claim TenantId 解析当前租户(泰额版登录写入)
10 9 /// </summary>
11 10 public class JwtClaimTenantResolveContributor : TenantResolveContributorBase
12 11 {
... ... @@ -16,16 +15,17 @@ public class JwtClaimTenantResolveContributor : TenantResolveContributorBase
16 15  
17 16 public override Task ResolveAsync(ITenantResolveContext context)
18 17 {
19   - var httpContext = context.ServiceProvider.GetService(typeof(IHttpContextAccessor)) as IHttpContextAccessor;
20   - var user = httpContext?.HttpContext?.User;
21   - if (user?.Identity?.IsAuthenticated != true)
  18 + var httpContext = (context.ServiceProvider.GetService(typeof(IHttpContextAccessor)) as IHttpContextAccessor)
  19 + ?.HttpContext;
  20 + if (httpContext is null)
22 21 {
23 22 return Task.CompletedTask;
24 23 }
25 24  
26   - var tenantClaim = user.FindFirst(TokenTypeConst.TenantId)?.Value
27   - ?? user.FindFirst(AbpClaimTypes.TenantId)?.Value;
28   - if (!string.IsNullOrWhiteSpace(tenantClaim))
  25 + var tenantClaim = TenantBusinessContextHelper.TryGetTenantIdClaimValue(httpContext);
  26 + if (!string.IsNullOrWhiteSpace(tenantClaim)
  27 + && Guid.TryParse(tenantClaim, out var tenantGuid)
  28 + && tenantGuid != Guid.Empty)
29 29 {
30 30 context.TenantIdOrName = tenantClaim;
31 31 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/TenantSqlScriptExecutor.cs 0 → 100644
  1 +using System.Text;
  2 +using System.Text.RegularExpressions;
  3 +using MySqlConnector;
  4 +using SqlSugar;
  5 +
  6 +namespace FoodLabeling.Application.MultiTenancy;
  7 +
  8 +/// <summary>
  9 +/// 在 MySQL 租户库上执行幂等 DDL/DML 脚本(支持 PREPARE / 用户变量等多语句批次)。
  10 +/// </summary>
  11 +public static class TenantSqlScriptExecutor
  12 +{
  13 + private static readonly Regex PrepareBlockEndRegex =
  14 + new(@"DEALLOCATE\s+PREPARE\s+\w+\s*;\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline);
  15 +
  16 + private static readonly Regex CreateTableEndRegex =
  17 + new(@"\)\s*ENGINE\s*=.+;\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
  18 +
  19 + /// <summary>
  20 + /// 执行完整 SQL 脚本;仅支持 MySQL。
  21 + /// </summary>
  22 + public static async Task ExecuteMySqlScriptAsync(
  23 + string connectionString,
  24 + string script,
  25 + CancellationToken cancellationToken = default)
  26 + {
  27 + if (string.IsNullOrWhiteSpace(script))
  28 + {
  29 + return;
  30 + }
  31 +
  32 + var normalizedConnectionString = EnsureMySqlScriptConnectionOptions(connectionString);
  33 + await using var connection = new MySqlConnection(normalizedConnectionString);
  34 + await connection.OpenAsync(cancellationToken);
  35 +
  36 + foreach (var batch in SplitIntoBatches(script))
  37 + {
  38 + await using var command = new MySqlCommand(batch, connection);
  39 + await command.ExecuteNonQueryAsync(cancellationToken);
  40 + }
  41 + }
  42 +
  43 + /// <summary>
  44 + /// 非 MySQL 租户库跳过脚本(当前脚本均为 MySQL 方言)。
  45 + /// </summary>
  46 + public static bool Supports(DbType dbType) => dbType == DbType.MySql;
  47 +
  48 + /// <summary>
  49 + /// 将脚本拆成可独立提交的批次:PREPARE 块、CREATE TABLE、UPDATE 等。
  50 + /// </summary>
  51 + internal static IReadOnlyList<string> SplitIntoBatches(string script)
  52 + {
  53 + var lines = script.Replace("\r\n", "\n").Split('\n');
  54 + var batches = new List<string>();
  55 + var current = new StringBuilder();
  56 +
  57 + foreach (var rawLine in lines)
  58 + {
  59 + var line = rawLine.TrimEnd();
  60 + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("--", StringComparison.Ordinal))
  61 + {
  62 + continue;
  63 + }
  64 +
  65 + current.AppendLine(line);
  66 +
  67 + var trimmed = line.Trim();
  68 + if (PrepareBlockEndRegex.IsMatch(trimmed)
  69 + || CreateTableEndRegex.IsMatch(trimmed)
  70 + || (trimmed.StartsWith("UPDATE ", StringComparison.OrdinalIgnoreCase) && trimmed.EndsWith(';')))
  71 + {
  72 + var batch = current.ToString().Trim();
  73 + if (!string.IsNullOrEmpty(batch))
  74 + {
  75 + batches.Add(batch);
  76 + }
  77 +
  78 + current.Clear();
  79 + }
  80 + }
  81 +
  82 + var tail = current.ToString().Trim();
  83 + if (!string.IsNullOrEmpty(tail))
  84 + {
  85 + batches.Add(tail);
  86 + }
  87 +
  88 + return batches;
  89 + }
  90 +
  91 + private static string EnsureMySqlScriptConnectionOptions(string connectionString)
  92 + {
  93 + var builder = new MySqlConnectionStringBuilder(connectionString)
  94 + {
  95 + AllowUserVariables = true
  96 + };
  97 +
  98 + return builder.ConnectionString;
  99 + }
  100 +}
... ...
泰额版/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;
3 3 using FoodLabeling.Application.Contracts.IServices;
4 4 using Microsoft.AspNetCore.Authorization;
5 5 using Microsoft.AspNetCore.Mvc;
  6 +using Microsoft.Extensions.Options;
6 7 using Volo.Abp;
7 8 using Volo.Abp.Application.Services;
8 9 using Volo.Abp.Caching;
... ... @@ -24,24 +25,28 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
24 25 private readonly IDistributedCache<SystemEditStampCacheItem, SystemEditStampCacheKey> _systemEditStampCache;
25 26 private readonly ISqlSugarDbContext _dbContext;
26 27 private readonly ISqlSugarRepository<UserAggregateRoot, Guid> _userRepository;
  28 + private readonly DbConnOptions _dbConnOptions;
27 29  
28 30 public AuthSessionAppService(
29 31 ISqlSugarDbContext dbContext,
30 32 ISqlSugarRepository<UserAggregateRoot, Guid> userRepository,
31 33 IDistributedCache<UserInfoCacheItem, UserInfoCacheKey> userCache,
32   - IDistributedCache<SystemEditStampCacheItem, SystemEditStampCacheKey> systemEditStampCache)
  34 + IDistributedCache<SystemEditStampCacheItem, SystemEditStampCacheKey> systemEditStampCache,
  35 + IOptions<DbConnOptions> dbConnOptions)
33 36 {
34 37 _dbContext = dbContext;
35 38 _userRepository = userRepository;
36 39 _userCache = userCache;
37 40 _systemEditStampCache = systemEditStampCache;
  41 + _dbConnOptions = dbConnOptions.Value;
38 42 }
39 43  
40 44 /// <inheritdoc />
41 45 public virtual async Task<CurrentUserMenuPermissionsOutputDto> GetMyMenusAsync()
42 46 {
43   - // 平台 Token(JWT 无 TenantId)读主库;公司 Token 须已解析租户上下文
44   - if (CurrentTenant.Id.HasValue)
  47 + // 平台 Token(JWT 无 TenantId,或前端误传 __tenant=全 0)读主库;
  48 + // 仅真实业务租户 Id 才要求已解析租户上下文
  49 + if (CurrentTenant.Id.HasValue && CurrentTenant.Id.Value != Guid.Empty)
45 50 {
46 51 TenantContextGuard.EnsureTenantResolved(CurrentTenant, "获取菜单权限");
47 52 }
... ... @@ -66,35 +71,33 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
66 71 .ToListAsync();
67 72 var distinctUserRoleIds = userRoleIds.Distinct().ToList();
68 73  
  74 + var isAdmin = UserConst.Admin.Equals(user.UserName);
  75 + var isSaasEnabled = _dbConnOptions.EnabledSaasMultiTenancy;
  76 + var isPlatformLogin = !CurrentTenant.Id.HasValue || CurrentTenant.Id.Value == Guid.Empty;
  77 +
69 78 List<MenuDbEntity> menus;
70   - if (UserConst.Admin.Equals(user.UserName))
  79 + // 非 SaaS:admin 返回全部;SaaS 公司业务租户:一律走 RoleMenu(含公司 admin)
  80 + // SaaS 平台登录:下方再统一过滤为仅平台菜单
  81 + if (isAdmin && !isSaasEnabled)
  82 + {
  83 + menus = await _dbContext.SqlSugarClient.Queryable<MenuDbEntity>()
  84 + .Where(x => x.IsDeleted == false)
  85 + .ToListAsync();
  86 + }
  87 + else if (isAdmin && isSaasEnabled && isPlatformLogin)
71 88 {
72   - // MenuAggregateRoot(ParentId 为 Guid) 无法兼容 menu.ParentId=0/字符串:这里统一用 MenuDbEntity
73 89 menus = await _dbContext.SqlSugarClient.Queryable<MenuDbEntity>()
74 90 .Where(x => x.IsDeleted == false)
75 91 .ToListAsync();
76 92 }
77 93 else
78 94 {
79   - var roleIdStrs = distinctUserRoleIds.Select(x => x.ToString()).Distinct().ToList();
80   - if (roleIdStrs.Count == 0)
81   - {
82   - menus = new List<MenuDbEntity>();
83   - }
84   - else
85   - {
86   - var menuIds = await _dbContext.SqlSugarClient.Queryable<RoleMenuDbEntity>()
87   - .Where(x => roleIdStrs.Contains(x.RoleId))
88   - .Select(x => x.MenuId)
89   - .Distinct()
90   - .ToListAsync();
91   -
92   - menus = menuIds.Count == 0
93   - ? new List<MenuDbEntity>()
94   - : await _dbContext.SqlSugarClient.Queryable<MenuDbEntity>()
95   - .Where(x => x.IsDeleted == false && menuIds.Contains(x.Id))
96   - .ToListAsync();
97   - }
  95 + menus = await LoadMenusByRoleIdsAsync(distinctUserRoleIds);
  96 + }
  97 +
  98 + if (isSaasEnabled && isPlatformLogin)
  99 + {
  100 + menus = PlatformMenuHelper.FilterPlatformMenusWithAncestors(menus);
98 101 }
99 102  
100 103 var menuNodes = menus
... ... @@ -126,6 +129,7 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
126 129  
127 130 return new CurrentUserMenuPermissionsOutputDto
128 131 {
  132 + UserId = user.Id,
129 133 User = new CurrentUserBriefDto
130 134 {
131 135 Id = user.Id,
... ... @@ -145,6 +149,30 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
145 149 }
146 150  
147 151 /// <summary>
  152 + /// 按用户角色关联 RoleMenu 查询可见菜单(SaaS 公司业务租户 admin 与普通用户相同路径)
  153 + /// </summary>
  154 + private async Task<List<MenuDbEntity>> LoadMenusByRoleIdsAsync(IReadOnlyList<Guid> userRoleIds)
  155 + {
  156 + var roleIdStrs = userRoleIds.Select(x => x.ToString()).Distinct().ToList();
  157 + if (roleIdStrs.Count == 0)
  158 + {
  159 + return new List<MenuDbEntity>();
  160 + }
  161 +
  162 + var menuIds = await _dbContext.SqlSugarClient.Queryable<RoleMenuDbEntity>()
  163 + .Where(x => roleIdStrs.Contains(x.RoleId))
  164 + .Select(x => x.MenuId)
  165 + .Distinct()
  166 + .ToListAsync();
  167 +
  168 + return menuIds.Count == 0
  169 + ? new List<MenuDbEntity>()
  170 + : await _dbContext.SqlSugarClient.Queryable<MenuDbEntity>()
  171 + .Where(x => x.IsDeleted == false && menuIds.Contains(x.Id))
  172 + .ToListAsync();
  173 + }
  174 +
  175 + /// <summary>
148 176 /// 缓存未命中时,取主要业务表最近修改时间,避免 Last Updated 长期停在用户资料时间。
149 177 /// </summary>
150 178 private async Task<DateTime?> ResolveRecentBusinessEditTimeAsync()
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs
1   -using System.Globalization;
  1 +using System.Globalization;
2 2 using System.Text.Json;
3 3 using FoodLabeling.Application.Contracts.Dtos.Dashboard;
4 4 using FoodLabeling.Application.Contracts.IServices;
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelAlertTimerDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +/// <summary>
  6 +/// 标签告警计时器(对应表:fl_label_alert_timer)
  7 +/// </summary>
  8 +[SugarTable("fl_label_alert_timer")]
  9 +public class FlLabelAlertTimerDbEntity
  10 +{
  11 + [SugarColumn(IsPrimaryKey = true)]
  12 + public string Id { get; set; } = string.Empty;
  13 +
  14 + public string BatchId { get; set; } = string.Empty;
  15 +
  16 + public string PrintTaskId { get; set; } = string.Empty;
  17 +
  18 + public string LabelId { get; set; } = string.Empty;
  19 +
  20 + public string? LabelCode { get; set; }
  21 +
  22 + public string LabelName { get; set; } = string.Empty;
  23 +
  24 + public string? ProductId { get; set; }
  25 +
  26 + public string? ProductName { get; set; }
  27 +
  28 + public string LocationId { get; set; } = string.Empty;
  29 +
  30 + public DateTime PrintedAt { get; set; }
  31 +
  32 + public DateTime? BaseTime { get; set; }
  33 +
  34 + public DateTime ExpiresAt { get; set; }
  35 +
  36 + public int DurationSeconds { get; set; }
  37 +
  38 + public string Title { get; set; } = string.Empty;
  39 +
  40 + public string Subtitle { get; set; } = string.Empty;
  41 +
  42 + public bool IsDeleted { get; set; }
  43 +
  44 + public DateTime? DeletionTime { get; set; }
  45 +
  46 + public string? CreatedBy { get; set; }
  47 +
  48 + public DateTime CreationTime { get; set; }
  49 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelCategoryDbEntity.cs
... ... @@ -49,6 +49,11 @@ public class FlLabelCategoryDbEntity
49 49 public string AvailabilityType { get; set; } = "ALL";
50 50  
51 51 /// <summary>
  52 + /// 适用 Region 范围:ALL / SPECIFIED(ALL 时可同时 SPECIFIED 门店快照,表示全区下指定门店)
  53 + /// </summary>
  54 + public string AppliedRegionType { get; set; } = "ALL";
  55 +
  56 + /// <summary>
52 57 /// 适用 Company 范围:ALL / SPECIFIED
53 58 /// </summary>
54 59 public string AppliedPartnerType { get; set; } = "ALL";
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelDbEntity.cs
... ... @@ -28,6 +28,11 @@ public class FlLabelDbEntity
28 28  
29 29 public string? LocationId { get; set; }
30 30  
  31 + /// <summary>
  32 + /// 适用 Company(<c>fl_partner.Id</c>,单选);Region/Location 为 ALL 时用于回显与范围校验。
  33 + /// </summary>
  34 + public string? PartnerId { get; set; }
  35 +
31 36 public string? LabelCategoryId { get; set; }
32 37  
33 38 public string? LabelTypeId { get; set; }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelMultipleOptionDbEntity.cs
... ... @@ -36,6 +36,11 @@ public class FlLabelMultipleOptionDbEntity
36 36 public string AvailabilityType { get; set; } = "ALL";
37 37  
38 38 /// <summary>
  39 + /// 适用 Region 范围:ALL / SPECIFIED
  40 + /// </summary>
  41 + public string AppliedRegionType { get; set; } = "ALL";
  42 +
  43 + /// <summary>
39 44 /// 适用 Company 范围:ALL / SPECIFIED
40 45 /// </summary>
41 46 public string AppliedPartnerType { get; set; } = "ALL";
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTypeDbEntity.cs
... ... @@ -34,6 +34,11 @@ public class FlLabelTypeDbEntity
34 34 public string AvailabilityType { get; set; } = "ALL";
35 35  
36 36 /// <summary>
  37 + /// 适用 Region 范围:ALL / SPECIFIED
  38 + /// </summary>
  39 + public string AppliedRegionType { get; set; } = "ALL";
  40 +
  41 + /// <summary>
37 42 /// 适用 Company 范围:ALL / SPECIFIED
38 43 /// </summary>
39 44 public string AppliedPartnerType { get; set; } = "ALL";
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs
... ... @@ -46,6 +46,16 @@ public class FlProductCategoryDbEntity
46 46 /// </summary>
47 47 public string AvailabilityType { get; set; } = "ALL";
48 48  
  49 + /// <summary>
  50 + /// 适用 Region 范围:ALL / SPECIFIED(ALL 时可同时 SPECIFIED 门店快照)
  51 + /// </summary>
  52 + public string AppliedRegionType { get; set; } = "ALL";
  53 +
  54 + /// <summary>
  55 + /// 适用 Company 范围:ALL / SPECIFIED
  56 + /// </summary>
  57 + public string AppliedPartnerType { get; set; } = "ALL";
  58 +
49 59 public int OrderNum { get; set; }
50 60 }
51 61  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryPartnerDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +[SugarTable("fl_product_category_partner")]
  6 +public class FlProductCategoryPartnerDbEntity
  7 +{
  8 + [SugarColumn(IsPrimaryKey = true)]
  9 + public string Id { get; set; } = string.Empty;
  10 +
  11 + public string CategoryId { get; set; } = string.Empty;
  12 +
  13 + public string PartnerId { get; set; } = string.Empty;
  14 +
  15 + public DateTime CreationTime { get; set; }
  16 +
  17 + public string? CreatorId { get; set; }
  18 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs
... ... @@ -36,4 +36,9 @@ public class FlProductDbEntity
36 36 /// 适用门店:ALL / SPECIFIED(ALL 时动态包含后续新增门店)
37 37 /// </summary>
38 38 public string AvailabilityType { get; set; } = "SPECIFIED";
  39 +
  40 + /// <summary>
  41 + /// 适用 Region:ALL / SPECIFIED(支持 Region=ALL + 指定门店)
  42 + /// </summary>
  43 + public string AppliedRegionType { get; set; } = "ALL";
39 44 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTeamMemberScopeDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +using Yi.Framework.SqlSugarCore.Abstractions;
  3 +
  4 +namespace FoodLabeling.Application.Services.DbModels;
  5 +
  6 +/// <summary>
  7 +/// Team Member 适用范围维度(Region/Location 的 ALL/SPECIFIED),与 userlocation 快照配合回显。
  8 +/// </summary>
  9 +[IgnoreCodeFirst]
  10 +[SugarTable("fl_team_member_scope")]
  11 +public class FlTeamMemberScopeDbEntity
  12 +{
  13 + [SugarColumn(IsPrimaryKey = true, Length = 36)]
  14 + public string UserId { get; set; } = string.Empty;
  15 +
  16 + /// <summary>适用 Region:ALL / SPECIFIED</summary>
  17 + [SugarColumn(Length = 20)]
  18 + public string AppliedRegionType { get; set; } = "SPECIFIED";
  19 +
  20 + /// <summary>适用 Location:ALL / SPECIFIED</summary>
  21 + [SugarColumn(Length = 20)]
  22 + public string AppliedLocationType { get; set; } = "SPECIFIED";
  23 +
  24 + public DateTime? CreationTime { get; set; }
  25 +
  26 + public DateTime? LastModificationTime { get; set; }
  27 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingCategoryDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +[SugarTable("fl_training_category")]
  6 +public class FlTrainingCategoryDbEntity
  7 +{
  8 + [SugarColumn(IsPrimaryKey = true)]
  9 + public string Id { get; set; } = string.Empty;
  10 +
  11 + public string CategoryName { get; set; } = string.Empty;
  12 +
  13 + public string? ParentId { get; set; }
  14 +
  15 + public int OrderNum { get; set; }
  16 +
  17 + public bool IsDeleted { get; set; }
  18 +
  19 + public DateTime CreationTime { get; set; }
  20 +
  21 + public string? CreatorId { get; set; }
  22 +
  23 + public DateTime? LastModificationTime { get; set; }
  24 +
  25 + public string? LastModifierId { get; set; }
  26 +
  27 + public string ConcurrencyStamp { get; set; } = string.Empty;
  28 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +[SugarTable("fl_training_file")]
  6 +public class FlTrainingFileDbEntity
  7 +{
  8 + [SugarColumn(IsPrimaryKey = true)]
  9 + public string Id { get; set; } = string.Empty;
  10 +
  11 + public string CategoryId { get; set; } = string.Empty;
  12 +
  13 + public string FileName { get; set; } = string.Empty;
  14 +
  15 + public string FileUrl { get; set; } = string.Empty;
  16 +
  17 + /// <summary>image / doc / other</summary>
  18 + public string FileType { get; set; } = "other";
  19 +
  20 + public long FileSize { get; set; }
  21 +
  22 + public int OrderNum { get; set; }
  23 +
  24 + public string AppliedPartnerType { get; set; } = "ALL";
  25 +
  26 + public string AppliedRegionType { get; set; } = "ALL";
  27 +
  28 + public string AvailabilityType { get; set; } = "ALL";
  29 +
  30 + public bool IsDeleted { get; set; }
  31 +
  32 + public DateTime CreationTime { get; set; }
  33 +
  34 + public string? CreatorId { get; set; }
  35 +
  36 + public DateTime? LastModificationTime { get; set; }
  37 +
  38 + public string? LastModifierId { get; set; }
  39 +
  40 + public string ConcurrencyStamp { get; set; } = string.Empty;
  41 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileLocationDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +[SugarTable("fl_training_file_location")]
  6 +public class FlTrainingFileLocationDbEntity
  7 +{
  8 + [SugarColumn(IsPrimaryKey = true)]
  9 + public string Id { get; set; } = string.Empty;
  10 +
  11 + public string TrainingFileId { get; set; } = string.Empty;
  12 +
  13 + public string LocationId { get; set; } = string.Empty;
  14 +
  15 + public DateTime CreationTime { get; set; }
  16 +
  17 + public string? CreatorId { get; set; }
  18 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFilePartnerDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +[SugarTable("fl_training_file_partner")]
  6 +public class FlTrainingFilePartnerDbEntity
  7 +{
  8 + [SugarColumn(IsPrimaryKey = true)]
  9 + public string Id { get; set; } = string.Empty;
  10 +
  11 + public string TrainingFileId { get; set; } = string.Empty;
  12 +
  13 + public string PartnerId { get; set; } = string.Empty;
  14 +
  15 + public DateTime CreationTime { get; set; }
  16 +
  17 + public string? CreatorId { get; set; }
  18 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlTrainingFileRegionDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +[SugarTable("fl_training_file_region")]
  6 +public class FlTrainingFileRegionDbEntity
  7 +{
  8 + [SugarColumn(IsPrimaryKey = true)]
  9 + public string Id { get; set; } = string.Empty;
  10 +
  11 + public string TrainingFileId { get; set; } = string.Empty;
  12 +
  13 + public string GroupId { get; set; } = string.Empty;
  14 +
  15 + public DateTime CreationTime { get; set; }
  16 +
  17 + public string? CreatorId { get; set; }
  18 +}
... ...
泰额版/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;
4 4 using FoodLabeling.Application.Contracts.IServices;
5 5 using FoodLabeling.Application.Services.DbModels;
6 6 using Microsoft.AspNetCore.Mvc;
  7 +using Microsoft.Extensions.Options;
7 8 using QuestPDF.Fluent;
8 9 using QuestPDF.Helpers;
9 10 using QuestPDF.Infrastructure;
... ... @@ -25,16 +26,22 @@ public class GroupAppService : ApplicationService, IGroupAppService
25 26  
26 27 private readonly ISqlSugarDbContext _dbContext;
27 28 private readonly IGuidGenerator _guidGenerator;
  29 + private readonly DbConnOptions _dbConnOptions;
28 30  
29   - public GroupAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator)
  31 + public GroupAppService(
  32 + ISqlSugarDbContext dbContext,
  33 + IGuidGenerator guidGenerator,
  34 + IOptions<DbConnOptions> dbConnOptions)
30 35 {
31 36 _dbContext = dbContext;
32 37 _guidGenerator = guidGenerator;
  38 + _dbConnOptions = dbConnOptions.Value;
33 39 }
34 40  
35 41 /// <inheritdoc />
36 42 public async Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input)
37 43 {
  44 + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Region");
38 45 RefAsync<int> total = 0;
39 46 var query = await BuildGroupJoinedQueryAsync(input);
40 47 var projected = query.Select((g, p) => new GroupGetListOutputDto
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAlertTimerAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +using FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer;
  3 +using FoodLabeling.Application.Contracts.IServices;
  4 +using FoodLabeling.Application.Helpers;
  5 +using FoodLabeling.Application.Services.DbModels;
  6 +using Microsoft.AspNetCore.Authorization;
  7 +using Microsoft.AspNetCore.Mvc;
  8 +using Microsoft.Extensions.Caching.Distributed;
  9 +using SqlSugar;
  10 +using Volo.Abp;
  11 +using Volo.Abp.Application.Services;
  12 +using Yi.Framework.SqlSugarCore.Abstractions;
  13 +
  14 +namespace FoodLabeling.Application.Services;
  15 +
  16 +/// <summary>
  17 +/// 标签告警计时器(App):跟踪已打印标签的过期倒计时;与可否打印无关。
  18 +/// </summary>
  19 +public class LabelAlertTimerAppService : ApplicationService, ILabelAlertTimerAppService
  20 +{
  21 + private const string StatusRunning = "running";
  22 + private const string StatusExpired = "expired";
  23 +
  24 + private readonly ISqlSugarDbContext _dbContext;
  25 + private readonly IDistributedCache _distributedCache;
  26 +
  27 + public LabelAlertTimerAppService(ISqlSugarDbContext dbContext, IDistributedCache distributedCache)
  28 + {
  29 + _dbContext = dbContext;
  30 + _distributedCache = distributedCache;
  31 + }
  32 +
  33 + /// <summary>
  34 + /// 分页查询当前门店告警计时器列表(含倒计时)
  35 + /// </summary>
  36 + /// <remarks>
  37 + /// 仅展示已打印标签的过期倒计时,<b>不</b>用于判断能否打印。
  38 + /// 过期时刻与 Print Log「Expiration」列同源(<c>ReportsPrintLogExpiryHelper</c>)。
  39 + /// 同一打印批次(<c>BatchId</c>)无论打印多少张标签,仅一条计时器(取 CopyIndex 最小任务)。
  40 + ///
  41 + /// 示例请求:
  42 + /// ```json
  43 + /// {
  44 + /// "locationId": "11111111-1111-1111-1111-111111111111",
  45 + /// "skipCount": 1,
  46 + /// "maxResultCount": 20,
  47 + /// "dateDay": "2026-08-07"
  48 + /// }
  49 + /// ```
  50 + ///
  51 + /// 参数说明:
  52 + /// - locationId: 当前门店 Id(必填,须已绑定)
  53 + /// - skipCount: 页码(从 1 开始)
  54 + /// - maxResultCount: 每页条数
  55 + /// - dateDay: 可选,按 PrintedAt 自然日筛选(yyyy-MM-dd)
  56 + /// </remarks>
  57 + /// <param name="input">分页查询入参</param>
  58 + /// <returns>分页计时器列表(含 remainingTime 倒计时秒数)</returns>
  59 + /// <response code="200">成功返回分页列表</response>
  60 + /// <response code="400">参数错误或未登录/无门店权限</response>
  61 + /// <response code="500">服务器错误</response>
  62 + [Authorize]
  63 + [HttpPost("label-alert-timer/list")]
  64 + public virtual async Task<PagedResultWithPageDto<LabelAlertTimerListItemDto>> GetListAsync(
  65 + LabelAlertTimerGetListInputVo input)
  66 + {
  67 + if (input is null)
  68 + {
  69 + throw new UserFriendlyException("入参不能为空");
  70 + }
  71 +
  72 + if (!CurrentUser.Id.HasValue)
  73 + {
  74 + throw new UserFriendlyException("用户未登录");
  75 + }
  76 +
  77 + var locationId = input.LocationId?.Trim();
  78 + if (string.IsNullOrWhiteSpace(locationId))
  79 + {
  80 + throw new UserFriendlyException("门店Id不能为空");
  81 + }
  82 +
  83 + return await QueryListByLocationAsync(locationId, input);
  84 + }
  85 +
  86 + /// <summary>
  87 + /// App:当前账号当前门店告警列表(含倒计时)
  88 + /// </summary>
  89 + /// <remarks>
  90 + /// 供 App 警告页使用:按当前登录账号可访问的门店查询已打印标签的告警倒计时。
  91 + /// <c>locationId</c> 可省略,省略时使用管理员已选门店缓存(<c>select-admin-scope-location</c>);
  92 + /// 仍无门店时返回友好错误。过期状态仅用于展示,与可否打印无关。
  93 + ///
  94 + /// 示例请求:
  95 + /// ```json
  96 + /// {
  97 + /// "locationId": "11111111-1111-1111-1111-111111111111",
  98 + /// "skipCount": 1,
  99 + /// "maxResultCount": 50
  100 + /// }
  101 + /// ```
  102 + ///
  103 + /// 参数说明:
  104 + /// - locationId: 当前门店 Id(可选;空则取已选门店缓存)
  105 + /// - skipCount: 页码(从 1 开始)
  106 + /// - maxResultCount: 每页条数
  107 + /// - dateDay: 可选,按 PrintedAt 自然日筛选(yyyy-MM-dd)
  108 + ///
  109 + /// 出参倒计时字段:
  110 + /// - remainingTime: 剩余秒数,App 可直接做倒计时
  111 + /// - totalTime: 总时长(秒)
  112 + /// - status: running / expired
  113 + /// - expiresAt: 过期时刻
  114 + /// </remarks>
  115 + /// <param name="input">分页查询入参</param>
  116 + /// <returns>分页告警列表(含倒计时)</returns>
  117 + /// <response code="200">成功返回分页列表</response>
  118 + /// <response code="400">未登录、无门店或无权限</response>
  119 + /// <response code="500">服务器错误</response>
  120 + [Authorize]
  121 + [HttpPost("label-alert-timer/app-list")]
  122 + public virtual async Task<PagedResultWithPageDto<LabelAlertTimerListItemDto>> GetAppListAsync(
  123 + LabelAlertTimerGetListInputVo input)
  124 + {
  125 + if (input is null)
  126 + {
  127 + throw new UserFriendlyException("入参不能为空");
  128 + }
  129 +
  130 + if (!CurrentUser.Id.HasValue)
  131 + {
  132 + throw new UserFriendlyException("用户未登录");
  133 + }
  134 +
  135 + var locationId = input.LocationId?.Trim();
  136 + if (string.IsNullOrWhiteSpace(locationId))
  137 + {
  138 + var cache = await UsAppAuthScopeHelper.GetAdminScopeCacheAsync(
  139 + _distributedCache,
  140 + CurrentUser.Id.Value);
  141 + locationId = cache?.Location?.Id?.Trim();
  142 + }
  143 +
  144 + if (string.IsNullOrWhiteSpace(locationId))
  145 + {
  146 + throw new UserFriendlyException("请先选择门店或传入 locationId");
  147 + }
  148 +
  149 + return await QueryListByLocationAsync(locationId, input);
  150 + }
  151 +
  152 + /// <summary>
  153 + /// 软删除告警计时器
  154 + /// </summary>
  155 + /// <remarks>
  156 + /// 删除前校验当前用户可访问该计时器所属门店。
  157 + /// </remarks>
  158 + /// <param name="id">计时器 Id</param>
  159 + /// <response code="200">删除成功</response>
  160 + /// <response code="400">记录不存在或无门店权限</response>
  161 + /// <response code="500">服务器错误</response>
  162 + [Authorize]
  163 + [HttpDelete("label-alert-timer/{id}")]
  164 + public virtual async Task DeleteAsync(string id)
  165 + {
  166 + var timerId = id?.Trim();
  167 + if (string.IsNullOrWhiteSpace(timerId))
  168 + {
  169 + throw new UserFriendlyException("计时器Id不能为空");
  170 + }
  171 +
  172 + if (!CurrentUser.Id.HasValue)
  173 + {
  174 + throw new UserFriendlyException("用户未登录");
  175 + }
  176 +
  177 + var db = _dbContext.SqlSugarClient;
  178 + var row = (await db.Queryable<FlLabelAlertTimerDbEntity>()
  179 + .Where(x => x.Id == timerId && !x.IsDeleted)
  180 + .Take(1)
  181 + .ToListAsync())
  182 + .FirstOrDefault();
  183 + if (row is null)
  184 + {
  185 + throw new UserFriendlyException("计时器不存在或已删除");
  186 + }
  187 +
  188 + await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync(
  189 + CurrentUser, db, row.LocationId);
  190 +
  191 + var now = DateTime.Now;
  192 + await db.Updateable<FlLabelAlertTimerDbEntity>()
  193 + .SetColumns(x => x.IsDeleted == true)
  194 + .SetColumns(x => x.DeletionTime == now)
  195 + .Where(x => x.Id == timerId && !x.IsDeleted)
  196 + .ExecuteCommandAsync();
  197 + }
  198 +
  199 + /// <summary>
  200 + /// 查询已打印标签告警的过期/倒计时状态(不拦截打印)
  201 + /// </summary>
  202 + /// <remarks>
  203 + /// 至少提供 <c>timerId</c>、<c>batchId</c>、<c>printTaskId</c> 之一。
  204 + /// 本接口仅返回已打印批次的过期状态与剩余秒数,供展示倒计时;
  205 + /// <b>绝不</b>用于判断「能不能打印」——打印流程不得依赖本接口结果做拦截。
  206 + ///
  207 + /// 示例请求:
  208 + /// ```json
  209 + /// {
  210 + /// "batchId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  211 + /// }
  212 + /// ```
  213 + ///
  214 + /// 参数说明:
  215 + /// - timerId: 计时器 Id
  216 + /// - batchId: 打印批次 Id
  217 + /// - printTaskId: 打印任务 Id(同批次任意任务均可)
  218 + /// </remarks>
  219 + /// <param name="input">查询入参</param>
  220 + /// <returns>过期/倒计时状态(展示用)</returns>
  221 + /// <response code="200">成功(含未找到记录的情况)</response>
  222 + /// <response code="400">未提供任何标识</response>
  223 + /// <response code="500">服务器错误</response>
  224 + [Authorize]
  225 + [HttpPost("label-alert-timer/check-expired")]
  226 + public virtual async Task<LabelAlertTimerCheckExpiredOutputDto> CheckExpiredAsync(
  227 + LabelAlertTimerCheckExpiredInputVo input)
  228 + {
  229 + if (input is null)
  230 + {
  231 + throw new UserFriendlyException("入参不能为空");
  232 + }
  233 +
  234 + var timerId = input.TimerId?.Trim();
  235 + var batchId = input.BatchId?.Trim();
  236 + var printTaskId = input.PrintTaskId?.Trim();
  237 + if (string.IsNullOrWhiteSpace(timerId) &&
  238 + string.IsNullOrWhiteSpace(batchId) &&
  239 + string.IsNullOrWhiteSpace(printTaskId))
  240 + {
  241 + throw new UserFriendlyException("请至少提供 timerId、batchId 或 printTaskId 之一");
  242 + }
  243 +
  244 + var db = _dbContext.SqlSugarClient;
  245 + FlLabelAlertTimerDbEntity? row = null;
  246 +
  247 + if (!string.IsNullOrWhiteSpace(timerId))
  248 + {
  249 + row = (await db.Queryable<FlLabelAlertTimerDbEntity>()
  250 + .Where(x => x.Id == timerId && !x.IsDeleted)
  251 + .Take(1)
  252 + .ToListAsync())
  253 + .FirstOrDefault();
  254 + }
  255 + else if (!string.IsNullOrWhiteSpace(batchId))
  256 + {
  257 + row = (await db.Queryable<FlLabelAlertTimerDbEntity>()
  258 + .Where(x => x.BatchId == batchId && !x.IsDeleted)
  259 + .Take(1)
  260 + .ToListAsync())
  261 + .FirstOrDefault();
  262 + }
  263 + else if (!string.IsNullOrWhiteSpace(printTaskId))
  264 + {
  265 + var task = (await db.Queryable<FlLabelPrintTaskDbEntity>()
  266 + .Where(x => x.Id == printTaskId)
  267 + .Take(1)
  268 + .ToListAsync())
  269 + .FirstOrDefault();
  270 + if (task is not null && !string.IsNullOrWhiteSpace(task.BatchId))
  271 + {
  272 + row = (await db.Queryable<FlLabelAlertTimerDbEntity>()
  273 + .Where(x => x.BatchId == task.BatchId && !x.IsDeleted)
  274 + .Take(1)
  275 + .ToListAsync())
  276 + .FirstOrDefault();
  277 + }
  278 + }
  279 +
  280 + if (row is null)
  281 + {
  282 + return new LabelAlertTimerCheckExpiredOutputDto
  283 + {
  284 + Found = false,
  285 + IsExpired = false,
  286 + RemainingSeconds = 0
  287 + };
  288 + }
  289 +
  290 + if (!CurrentUser.Id.HasValue)
  291 + {
  292 + throw new UserFriendlyException("用户未登录");
  293 + }
  294 +
  295 + await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync(
  296 + CurrentUser, db, row.LocationId);
  297 +
  298 + var now = DateTime.Now;
  299 + var remaining = Math.Max(0, (int)(row.ExpiresAt - now).TotalSeconds);
  300 + var isExpired = row.ExpiresAt <= now;
  301 +
  302 + return new LabelAlertTimerCheckExpiredOutputDto
  303 + {
  304 + Found = true,
  305 + IsExpired = isExpired,
  306 + ExpiresAt = row.ExpiresAt,
  307 + RemainingSeconds = remaining,
  308 + Status = isExpired ? StatusExpired : StatusRunning,
  309 + Title = row.Title,
  310 + Subtitle = row.Subtitle,
  311 + TimerId = row.Id,
  312 + BatchId = row.BatchId
  313 + };
  314 + }
  315 +
  316 + private async Task<PagedResultWithPageDto<LabelAlertTimerListItemDto>> QueryListByLocationAsync(
  317 + string locationId,
  318 + LabelAlertTimerGetListInputVo input)
  319 + {
  320 + await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync(
  321 + CurrentUser, _dbContext.SqlSugarClient, locationId);
  322 +
  323 + var db = _dbContext.SqlSugarClient;
  324 + RefAsync<int> total = 0;
  325 + var query = db.Queryable<FlLabelAlertTimerDbEntity>()
  326 + .Where(x => !x.IsDeleted && x.LocationId == locationId);
  327 +
  328 + var (dayStart, dayEndExcl) = ResolveDateDayFilter(input.DateDay);
  329 + if (dayStart.HasValue && dayEndExcl.HasValue)
  330 + {
  331 + var start = dayStart.Value;
  332 + var endExcl = dayEndExcl.Value;
  333 + query = query.Where(x => x.PrintedAt >= start && x.PrintedAt < endExcl);
  334 + }
  335 +
  336 + var pageRows = await query
  337 + .OrderBy(x => x.ExpiresAt, OrderByType.Desc)
  338 + .OrderBy(x => x.PrintedAt, OrderByType.Desc)
  339 + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
  340 +
  341 + var now = DateTime.Now;
  342 + var items = pageRows.Select(x => MapListItem(x, now)).ToList();
  343 +
  344 + var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount;
  345 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
  346 + var totalCount = (long)total;
  347 + var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize);
  348 +
  349 + return new PagedResultWithPageDto<LabelAlertTimerListItemDto>
  350 + {
  351 + PageIndex = pageIndex,
  352 + PageSize = pageSize,
  353 + TotalCount = totalCount,
  354 + TotalPages = totalPages,
  355 + Items = items
  356 + };
  357 + }
  358 +
  359 + private static LabelAlertTimerListItemDto MapListItem(FlLabelAlertTimerDbEntity row, DateTime now)
  360 + {
  361 + var remaining = Math.Max(0, (int)(row.ExpiresAt - now).TotalSeconds);
  362 + var isExpired = row.ExpiresAt <= now;
  363 +
  364 + return new LabelAlertTimerListItemDto
  365 + {
  366 + Id = row.Id,
  367 + BatchId = row.BatchId,
  368 + PrintTaskId = row.PrintTaskId,
  369 + LabelId = row.LabelId,
  370 + LabelCode = row.LabelCode,
  371 + Title = row.Title,
  372 + Subtitle = row.Subtitle,
  373 + TotalTime = row.DurationSeconds,
  374 + RemainingTime = remaining,
  375 + Status = isExpired ? StatusExpired : StatusRunning,
  376 + ExpiresAt = row.ExpiresAt,
  377 + PrintedAt = row.PrintedAt,
  378 + LocationId = row.LocationId,
  379 + ProductName = row.ProductName
  380 + };
  381 + }
  382 +
  383 + private static (DateTime? DayStart, DateTime? DayEndExcl) ResolveDateDayFilter(string? dateDay)
  384 + {
  385 + if (string.IsNullOrWhiteSpace(dateDay))
  386 + {
  387 + return (null, null);
  388 + }
  389 +
  390 + if (DateTime.TryParse(dateDay.Trim(), out var parsedDay))
  391 + {
  392 + var day = parsedDay.Date;
  393 + return (day, day.AddDays(1));
  394 + }
  395 +
  396 + return (null, null);
  397 + }
  398 +}
... ...