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,10 +21,13 @@ namespace Yi.Framework.SqlSugarCore
21 { 21 {
22 #region Properties 22 #region Properties
23 23
  24 + private ISqlSugarClient? _sqlSugarClient;
  25 + private readonly object _clientLock = new();
  26 +
24 /// <summary> 27 /// <summary>
25 - /// SqlSugar客户端实例 28 + /// SqlSugar 客户端(延迟按当前租户解析连接串,避免构造时 CurrentTenant 尚未就绪落到 host 库)
26 /// </summary> 29 /// </summary>
27 - public ISqlSugarClient SqlSugarClient { get; private set; } 30 + public ISqlSugarClient SqlSugarClient => GetOrCreateClient();
28 31
29 /// <summary> 32 /// <summary>
30 /// 延迟服务提供者 33 /// 延迟服务提供者
@@ -75,22 +78,62 @@ namespace Yi.Framework.SqlSugarCore @@ -75,22 +78,62 @@ namespace Yi.Framework.SqlSugarCore
75 public SqlSugarDbContextFactory(IAbpLazyServiceProvider lazyServiceProvider) 78 public SqlSugarDbContextFactory(IAbpLazyServiceProvider lazyServiceProvider)
76 { 79 {
77 LazyServiceProvider = lazyServiceProvider; 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 /// <summary> 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,6 +5,9 @@ namespace FoodLabeling.Application.Contracts.Dtos.AuthSession;
5 /// </summary> 5 /// </summary>
6 public class CurrentUserBriefDto 6 public class CurrentUserBriefDto
7 { 7 {
  8 + /// <summary>
  9 + /// 当前登录用户 Id(非租户 Id)。
  10 + /// </summary>
8 public Guid Id { get; set; } 11 public Guid Id { get; set; }
9 12
10 public string UserName { get; set; } = string.Empty; 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,6 +5,11 @@ namespace FoodLabeling.Application.Contracts.Dtos.AuthSession;
5 /// </summary> 5 /// </summary>
6 public class CurrentUserMenuPermissionsOutputDto 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 public CurrentUserBriefDto User { get; set; } = new(); 13 public CurrentUserBriefDto User { get; set; } = new();
9 14
10 public List<string> RoleCodes { get; set; } = new(); 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,6 +13,9 @@ public class LabelBatchCreateItemInputVo
13 13
14 public List<string>? PartnerIds { get; set; } 14 public List<string>? PartnerIds { get; set; }
15 15
  16 + /// <summary>适用 Company(单选)</summary>
  17 + public List<string>? CompanyIds { get; set; }
  18 +
16 public string? AppliedRegionType { get; set; } 19 public string? AppliedRegionType { get; set; }
17 20
18 public List<string>? RegionIds { get; set; } 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,6 +2,7 @@ namespace FoodLabeling.Application.Contracts.Dtos.Label;
2 2
3 public class LabelCreateInputVo 3 public class LabelCreateInputVo
4 { 4 {
  5 + /// <summary>标签编码(可选;未传或空字符串时由后端自动生成唯一编码)</summary>
5 public string? LabelCode { get; set; } 6 public string? LabelCode { get; set; }
6 7
7 public string LabelName { get; set; } = string.Empty; 8 public string LabelName { get; set; } = string.Empty;
@@ -9,32 +10,38 @@ public class LabelCreateInputVo @@ -9,32 +10,38 @@ public class LabelCreateInputVo
9 public string TemplateCode { get; set; } = string.Empty; 10 public string TemplateCode { get; set; } = string.Empty;
10 11
11 /// <summary> 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 /// </summary> 14 /// </summary>
14 public string? PartnerId { get; set; } 15 public string? PartnerId { get; set; }
15 16
16 /// <summary> 17 /// <summary>
17 - /// 适用 Company 多选(<c>fl_partner.Id</c> 18 + /// 适用 Company 多选字段(仅支持 1 个;与 <see cref="CompanyIds"/> 相同
18 /// </summary> 19 /// </summary>
19 public List<string>? PartnerIds { get; set; } 20 public List<string>? PartnerIds { get; set; }
20 21
21 /// <summary> 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 /// </summary> 30 /// </summary>
24 public string? AppliedRegionType { get; set; } 31 public string? AppliedRegionType { get; set; }
25 32
26 /// <summary> 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 /// </summary> 35 /// </summary>
29 public List<string>? RegionIds { get; set; } 36 public List<string>? RegionIds { get; set; }
30 37
31 /// <summary> 38 /// <summary>
32 - /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同) 39 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同);可含 <c>ALL</c>
33 /// </summary> 40 /// </summary>
34 public List<string>? GroupIds { get; set; } 41 public List<string>? GroupIds { get; set; }
35 42
36 /// <summary> 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 /// </summary> 45 /// </summary>
39 public List<string>? LocationIds { get; set; } 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,11 +17,14 @@ public class LabelGetOutputDto
17 17
18 public string LocationName { get; set; } = string.Empty; 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 public string? PartnerId { get; set; } 21 public string? PartnerId { get; set; }
22 22
23 public List<string> PartnerIds { get; set; } = new(); 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 /// <summary>适用 Region 范围:ALL / SPECIFIED</summary> 28 /// <summary>适用 Region 范围:ALL / SPECIFIED</summary>
26 public string AppliedRegionType { get; set; } = "SPECIFIED"; 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,17 +10,23 @@ public class LabelUpdateInputVo
10 10
11 public List<string>? PartnerIds { get; set; } 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 /// <summary> 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 /// </summary> 19 /// </summary>
16 public string? AppliedRegionType { get; set; } 20 public string? AppliedRegionType { get; set; }
17 21
  22 + /// <summary>适用 Region 多选;可含 <c>ALL</c></summary>
18 public List<string>? RegionIds { get; set; } 23 public List<string>? RegionIds { get; set; }
19 24
  25 + /// <summary>与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c></summary>
20 public List<string>? GroupIds { get; set; } 26 public List<string>? GroupIds { get; set; }
21 27
22 /// <summary> 28 /// <summary>
23 - /// 适用门店 Id 数组(<c>location.Id</c>,落库 <c>fl_label_location</c>);主字段,全量覆盖 29 + /// 适用门店 Id 数组;可含 <c>ALL</c>;落库 <c>fl_label_location</c>,全量覆盖
24 /// </summary> 30 /// </summary>
25 public List<string>? LocationIds { get; set; } 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,7 +4,8 @@ namespace FoodLabeling.Application.Contracts.Dtos.LabelCategory;
4 4
5 public class LabelCategoryCreateInputVo : ILabelEntityPartnerScopeInput 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 public string CategoryName { get; set; } = string.Empty; 10 public string CategoryName { get; set; } = string.Empty;
10 11
@@ -37,25 +38,28 @@ public class LabelCategoryCreateInputVo : ILabelEntityPartnerScopeInput @@ -37,25 +38,28 @@ public class LabelCategoryCreateInputVo : ILabelEntityPartnerScopeInput
37 public List<string>? CompanyIds { get; set; } 38 public List<string>? CompanyIds { get; set; }
38 39
39 /// <summary> 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 /// </summary> 44 /// </summary>
42 public string AvailabilityType { get; set; } = "ALL"; 45 public string AvailabilityType { get; set; } = "ALL";
43 46
44 /// <summary> 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 /// </summary> 49 /// </summary>
47 public List<string>? RegionIds { get; set; } 50 public List<string>? RegionIds { get; set; }
48 51
49 /// <summary> 52 /// <summary>
50 - /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同 53 + /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c>
51 /// </summary> 54 /// </summary>
52 public List<string>? GroupIds { get; set; } 55 public List<string>? GroupIds { get; set; }
53 56
54 /// <summary> 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 /// </summary> 59 /// </summary>
57 public List<string>? LocationIds { get; set; } 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,7 +4,7 @@ namespace FoodLabeling.Application.Contracts.Dtos.LabelMultipleOption;
4 4
5 public class LabelMultipleOptionCreateInputVo : ILabelEntityPartnerScopeInput 5 public class LabelMultipleOptionCreateInputVo : ILabelEntityPartnerScopeInput
6 { 6 {
7 - /// <summary>多选项编码(可选;未传或空字符串时存空,列表/详情出参为「无」)</summary> 7 + /// <summary>多选项编码(可选;未传或空字符串时由后端自动生成唯一编码)</summary>
8 public string? OptionCode { get; set; } 8 public string? OptionCode { get; set; }
9 9
10 public string OptionName { get; set; } = string.Empty; 10 public string OptionName { get; set; } = string.Empty;
@@ -25,25 +25,27 @@ public class LabelMultipleOptionCreateInputVo : ILabelEntityPartnerScopeInput @@ -25,25 +25,27 @@ public class LabelMultipleOptionCreateInputVo : ILabelEntityPartnerScopeInput
25 public List<string>? CompanyIds { get; set; } 25 public List<string>? CompanyIds { get; set; }
26 26
27 /// <summary> 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 /// </summary> 30 /// </summary>
30 public string AvailabilityType { get; set; } = "ALL"; 31 public string AvailabilityType { get; set; } = "ALL";
31 32
32 /// <summary> 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 /// </summary> 35 /// </summary>
35 public List<string>? RegionIds { get; set; } 36 public List<string>? RegionIds { get; set; }
36 37
37 /// <summary> 38 /// <summary>
38 - /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同 39 + /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同。可传 <c>ALL</c>。
39 /// </summary> 40 /// </summary>
40 public List<string>? GroupIds { get; set; } 41 public List<string>? GroupIds { get; set; }
41 42
42 /// <summary> 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 /// </summary> 45 /// </summary>
45 public List<string>? LocationIds { get; set; } 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,7 +4,8 @@ namespace FoodLabeling.Application.Contracts.Dtos.LabelType;
4 4
5 public class LabelTypeCreateInputVo : ILabelEntityPartnerScopeInput 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 public string TypeName { get; set; } = string.Empty; 10 public string TypeName { get; set; } = string.Empty;
10 11
@@ -22,25 +23,28 @@ public class LabelTypeCreateInputVo : ILabelEntityPartnerScopeInput @@ -22,25 +23,28 @@ public class LabelTypeCreateInputVo : ILabelEntityPartnerScopeInput
22 public List<string>? CompanyIds { get; set; } 23 public List<string>? CompanyIds { get; set; }
23 24
24 /// <summary> 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 /// </summary> 29 /// </summary>
27 public string AvailabilityType { get; set; } = "ALL"; 30 public string AvailabilityType { get; set; } = "ALL";
28 31
29 /// <summary> 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 /// </summary> 34 /// </summary>
32 public List<string>? RegionIds { get; set; } 35 public List<string>? RegionIds { get; set; }
33 36
34 /// <summary> 37 /// <summary>
35 - /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同 38 + /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c>
36 /// </summary> 39 /// </summary>
37 public List<string>? GroupIds { get; set; } 40 public List<string>? GroupIds { get; set; }
38 41
39 /// <summary> 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 /// </summary> 44 /// </summary>
42 public List<string>? LocationIds { get; set; } 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 using System.Collections.Generic; 1 using System.Collections.Generic;
  2 +using System.Text.Json.Serialization;
  3 +using FoodLabeling.Application.Contracts.Json;
2 4
3 namespace FoodLabeling.Application.Contracts.Dtos.Product; 5 namespace FoodLabeling.Application.Contracts.Dtos.Product;
4 6
@@ -35,18 +37,46 @@ public class ProductCreateInputVo @@ -35,18 +37,46 @@ public class ProductCreateInputVo
35 public string? AvailabilityType { get; set; } 37 public string? AvailabilityType { get; set; }
36 38
37 /// <summary> 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 /// </summary> 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 public string? PartnerId { get; set; } 50 public string? PartnerId { get; set; }
41 51
42 /// <summary> 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 /// </summary> 69 /// </summary>
45 public List<string>? GroupIds { get; set; } 70 public List<string>? GroupIds { get; set; }
46 71
47 /// <summary> 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 /// </summary> 80 /// </summary>
51 public List<string>? LocationIds { get; set; } 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,17 +29,25 @@ public class ProductGetOutputDto
29 /// <summary>适用门店:ALL / SPECIFIED</summary> 29 /// <summary>适用门店:ALL / SPECIFIED</summary>
30 public string AvailabilityType { get; set; } = "SPECIFIED"; 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 public string? PartnerId { get; set; } 33 public string? PartnerId { get; set; }
34 34
35 - /// <summary>适用 Company Id 列表(去重)</summary> 35 + /// <summary>适用 Company Id 列表(去重;历史数据可能多条)</summary>
36 public List<string> PartnerIds { get; set; } = new(); 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 /// <summary>适用 Region Id(<c>fl_group.Id</c>,由关联门店反推)</summary> 43 /// <summary>适用 Region Id(<c>fl_group.Id</c>,由关联门店反推)</summary>
39 public List<string> GroupIds { get; set; } = new(); 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 /// <summary> 49 /// <summary>
42 - /// 适用门店 Id 列表(来自 fl_location_product 50 + /// 适用门店 Id 列表(来自 fl_location_product;覆盖 Region 全集时可折叠为 <c>["ALL"]</c>
43 /// </summary> 51 /// </summary>
44 public List<string> LocationIds { get; set; } = new(); 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 namespace FoodLabeling.Application.Contracts.Dtos.ProductCategory; 1 namespace FoodLabeling.Application.Contracts.Dtos.ProductCategory;
2 2
  3 +using FoodLabeling.Application.Contracts.Dtos.Common;
  4 +
3 /// <summary> 5 /// <summary>
4 /// 产品模块:新增类别入参 6 /// 产品模块:新增类别入参
5 /// </summary> 7 /// </summary>
6 -public class ProductCategoryCreateInputVo 8 +public class ProductCategoryCreateInputVo : ILabelEntityPartnerScopeInput
7 { 9 {
8 - /// <summary>  
9 - /// 类别编码(可选,不传或空字符串表示无编码)  
10 - /// </summary> 10 + /// <summary>类别编码(可选;未传或空字符串时由后端自动生成唯一编码)</summary>
11 public string? CategoryCode { get; set; } 11 public string? CategoryCode { get; set; }
12 12
13 public string CategoryName { get; set; } = string.Empty; 13 public string CategoryName { get; set; } = string.Empty;
@@ -30,25 +30,40 @@ public class ProductCategoryCreateInputVo @@ -30,25 +30,40 @@ public class ProductCategoryCreateInputVo
30 public bool State { get; set; } = true; 30 public bool State { get; set; } = true;
31 31
32 /// <summary> 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 /// </summary> 48 /// </summary>
35 public string AvailabilityType { get; set; } = "ALL"; 49 public string AvailabilityType { get; set; } = "ALL";
36 50
37 /// <summary> 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 /// </summary> 53 /// </summary>
40 public List<string>? RegionIds { get; set; } 54 public List<string>? RegionIds { get; set; }
41 55
42 /// <summary> 56 /// <summary>
43 - /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同;传任一会合并去重 57 + /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c>
44 /// </summary> 58 /// </summary>
45 public List<string>? GroupIds { get; set; } 59 public List<string>? GroupIds { get; set; }
46 60
47 /// <summary> 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 /// </summary> 63 /// </summary>
50 public List<string>? LocationIds { get; set; } 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,6 +21,16 @@ public class ProductCategoryGetListOutputDto
21 21
22 public string AvailabilityType { get; set; } = "ALL"; 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 public int OrderNum { get; set; } 34 public int OrderNum { get; set; }
25 35
26 public DateTime LastEdited { get; set; } 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,6 +22,16 @@ public class ProductCategoryGetOutputDto
22 22
23 public string AvailabilityType { get; set; } = "ALL"; 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 /// <summary>适用 Region Id 列表(多选,<c>fl_group.Id</c>;由绑定门店反推)</summary> 35 /// <summary>适用 Region Id 列表(多选,<c>fl_group.Id</c>;由绑定门店反推)</summary>
26 public List<string> RegionIds { get; set; } = new(); 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,6 +9,12 @@ namespace FoodLabeling.Application.Contracts.Dtos.ProductLocation;
9 public class ProductLocationGetListInputVo : PagedAndSortedResultRequestDto 9 public class ProductLocationGetListInputVo : PagedAndSortedResultRequestDto
10 { 10 {
11 /// <summary> 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 /// 门店Id(location.Id,string 表示) 18 /// 门店Id(location.Id,string 表示)
13 /// </summary> 19 /// </summary>
14 public string? LocationId { get; set; } 20 public string? LocationId { get; set; }
@@ -18,4 +24,3 @@ public class ProductLocationGetListInputVo : PagedAndSortedResultRequestDto @@ -18,4 +24,3 @@ public class ProductLocationGetListInputVo : PagedAndSortedResultRequestDto
18 /// </summary> 24 /// </summary>
19 public string? ProductId { get; set; } 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,11 +24,16 @@ public class RbacRoleCreateInputVo
24 public int? OrderNum { get; set; } 24 public int? OrderNum { get; set; }
25 25
26 /// <summary> 26 /// <summary>
27 - /// 绑定菜单 Id;与 accessPermissions 同时传时以本字段为准 27 + /// 绑定菜单 Id;与 menuPermissionKeys / accessPermissions 同时传时以本字段为准
28 /// </summary> 28 /// </summary>
29 public List<Guid>? MenuIds { get; set; } 29 public List<Guid>? MenuIds { get; set; }
30 30
31 /// <summary> 31 /// <summary>
  32 + /// 绑定菜单 Id(字符串 Guid 列表);与 <see cref="MenuIds"/> 等价,<see cref="MenuIds"/> 优先
  33 + /// </summary>
  34 + public List<string>? MenuPermissionKeys { get; set; }
  35 +
  36 + /// <summary>
32 /// 按 PermissionCode 绑定菜单:JSON 数组字符串(如 <c>["manage_labels"]</c>)、英文逗号分隔;传空字符串表示清空绑定;不传则不修改已有绑定(仅编辑时) 37 /// 按 PermissionCode 绑定菜单:JSON 数组字符串(如 <c>["manage_labels"]</c>)、英文逗号分隔;传空字符串表示清空绑定;不传则不修改已有绑定(仅编辑时)
33 /// </summary> 38 /// </summary>
34 public string? AccessPermissions { get; set; } 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,5 +28,10 @@ public class RbacRoleGetListOutputDto
28 /// 角色访问权限码列表(与库字段 AccessPermissionCodes 对应) 28 /// 角色访问权限码列表(与库字段 AccessPermissionCodes 对应)
29 /// </summary> 29 /// </summary>
30 public List<string> AccessPermissionCodes { get; set; } = new(); 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,20 +28,28 @@ public class TeamMemberCreateInputVo
28 public List<string>? PartnerIds { get; set; } 28 public List<string>? PartnerIds { get; set; }
29 29
30 /// <summary> 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 /// </summary> 33 /// </summary>
33 public List<string>? RegionIds { get; set; } 34 public List<string>? RegionIds { get; set; }
34 35
35 /// <summary> 36 /// <summary>
36 - /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同) 37 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同);可含 <c>ALL</c>。
37 /// </summary> 38 /// </summary>
38 public List<string>? GroupIds { get; set; } 39 public List<string>? GroupIds { get; set; }
39 40
40 /// <summary> 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 /// </summary> 45 /// </summary>
43 public List<string>? LocationIds { get; set; } 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 public bool State { get; set; } = true; 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,12 +19,13 @@ public class TeamMemberGetOutputDto
19 /// <summary>适用 Company Id(多选,由绑定门店反推)</summary> 19 /// <summary>适用 Company Id(多选,由绑定门店反推)</summary>
20 public List<string> PartnerIds { get; set; } = new(); 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 public List<string> RegionIds { get; set; } = new(); 23 public List<string> RegionIds { get; set; } = new();
24 24
25 /// <summary>与 <see cref="RegionIds"/> 相同</summary> 25 /// <summary>与 <see cref="RegionIds"/> 相同</summary>
26 public List<string> GroupIds { get; set; } = new(); 26 public List<string> GroupIds { get; set; } = new();
27 27
  28 + /// <summary>绑定门店 Id;覆盖该公司全部门店时回显 <c>["ALL"]</c>(库中仍存展开 Guid)</summary>
28 public List<string> LocationIds { get; set; } = new(); 29 public List<string> LocationIds { get; set; } = new();
29 30
30 public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new(); 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,20 +28,26 @@ public class TeamMemberUpdateInputVo
28 public List<string>? PartnerIds { get; set; } 28 public List<string>? PartnerIds { get; set; }
29 29
30 /// <summary> 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 /// </summary> 32 /// </summary>
33 public List<string>? RegionIds { get; set; } 33 public List<string>? RegionIds { get; set; }
34 34
35 /// <summary> 35 /// <summary>
36 - /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同) 36 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同);可含 <c>ALL</c>。
37 /// </summary> 37 /// </summary>
38 public List<string>? GroupIds { get; set; } 38 public List<string>? GroupIds { get; set; }
39 39
40 /// <summary> 40 /// <summary>
41 - /// 适用门店多选(<c>location.Id</c>);Company Admin 仅传 Company 时可省略 41 + /// 适用门店多选(<c>location.Id</c>);可含 <c>ALL</c> 哨兵。
  42 + /// 有具体 Region 时表示该 Region 下全部门店;仅有 Company 时按公司全部门店落库。
42 /// </summary> 43 /// </summary>
43 public List<string>? LocationIds { get; set; } 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 public bool State { get; set; } = true; 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,8 +12,11 @@ public interface IAuthSessionAppService : IApplicationService
12 /// 获取当前登录用户的角色编码、权限码与可见菜单树 12 /// 获取当前登录用户的角色编码、权限码与可见菜单树
13 /// </summary> 13 /// </summary>
14 /// <remarks> 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 /// 角色名通过 <c>Role</c> 表直查(<c>RoleDbEntity</c>),避免走仓储 IDataPermission。 20 /// 角色名通过 <c>Role</c> 表直查(<c>RoleDbEntity</c>),避免走仓储 IDataPermission。
18 /// </remarks> 21 /// </remarks>
19 /// <returns>用户简要信息、权限码与菜单树</returns> 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,7 +21,8 @@ public interface ILabelAppService : IApplicationService
21 Task<LabelGetOutputDto> GetAsync(string id); 21 Task<LabelGetOutputDto> GetAsync(string id);
22 22
23 /// <summary> 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 /// </summary> 26 /// </summary>
26 Task<LabelGetOutputDto> CreateAsync(LabelCreateInputVo input); 27 Task<LabelGetOutputDto> CreateAsync(LabelCreateInputVo input);
27 28
@@ -31,7 +32,7 @@ public interface ILabelAppService : IApplicationService @@ -31,7 +32,7 @@ public interface ILabelAppService : IApplicationService
31 Task<LabelBatchCreateResultDto> BatchCreateAsync(LabelBatchCreateInputVo input); 32 Task<LabelBatchCreateResultDto> BatchCreateAsync(LabelBatchCreateInputVo input);
32 33
33 /// <summary> 34 /// <summary>
34 - /// 编辑标签(id=LabelCode)。适用 Region / 门店字段同 <see cref="CreateAsync"/>。 35 + /// 编辑标签(id=LabelCode)。范围传参与 <see cref="CreateAsync"/> 一致,支持 Id 数组传 <c>ALL</c>。
35 /// </summary> 36 /// </summary>
36 Task<LabelGetOutputDto> UpdateAsync(string id, LabelUpdateInputVo input); 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,8 +9,10 @@ public interface ILabelCategoryAppService
9 9
10 Task<LabelCategoryGetOutputDto> GetAsync(string id); 10 Task<LabelCategoryGetOutputDto> GetAsync(string id);
11 11
  12 + /// <summary>新增标签分类;<c>regionIds</c>/<c>groupIds</c>/<c>locationIds</c> 支持哨兵 <c>ALL</c>。</summary>
12 Task<LabelCategoryGetOutputDto> CreateAsync(LabelCategoryCreateInputVo input); 13 Task<LabelCategoryGetOutputDto> CreateAsync(LabelCategoryCreateInputVo input);
13 14
  15 + /// <summary>编辑标签分类;范围传参与新增一致,支持 <c>ALL</c>。</summary>
14 Task<LabelCategoryGetOutputDto> UpdateAsync(string id, LabelCategoryUpdateInputVo input); 16 Task<LabelCategoryGetOutputDto> UpdateAsync(string id, LabelCategoryUpdateInputVo input);
15 17
16 Task DeleteAsync(string id); 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,8 +9,14 @@ public interface ILabelMultipleOptionAppService
9 9
10 Task<LabelMultipleOptionGetOutputDto> GetAsync(string id); 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 Task<LabelMultipleOptionGetOutputDto> CreateAsync(LabelMultipleOptionCreateInputVo input); 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 Task<LabelMultipleOptionGetOutputDto> UpdateAsync(string id, LabelMultipleOptionUpdateInputVo input); 20 Task<LabelMultipleOptionGetOutputDto> UpdateAsync(string id, LabelMultipleOptionUpdateInputVo input);
15 21
16 Task DeleteAsync(string id); 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,12 +25,14 @@ public interface ILabelTemplateAppService : IApplicationService
25 25
26 /// <summary> 26 /// <summary>
27 /// 新增标签模板;body 支持 Company/Region/Location 三维范围(各维度 ALL/SPECIFIED + Id 数组)。 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 /// </summary> 29 /// </summary>
29 Task<LabelTemplateGetOutputDto> CreateAsync(LabelTemplateCreateInputVo input); 30 Task<LabelTemplateGetOutputDto> CreateAsync(LabelTemplateCreateInputVo input);
30 31
31 /// <summary> 32 /// <summary>
32 /// 编辑标签模板(版本号 +1,重建 elements);适用范围多选规则同新增。 33 /// 编辑标签模板(版本号 +1,重建 elements);适用范围多选规则同新增。
33 /// body 支持 <c>printOrientation</c>(<c>vertical</c> / <c>horizontal</c>,横打不交换 Width/Height)。 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 /// </summary> 36 /// </summary>
35 Task<LabelTemplateGetOutputDto> UpdateAsync(string id, LabelTemplateUpdateInputVo input); 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,8 +9,10 @@ public interface ILabelTypeAppService
9 9
10 Task<LabelTypeGetOutputDto> GetAsync(string id); 10 Task<LabelTypeGetOutputDto> GetAsync(string id);
11 11
  12 + /// <summary>新增标签类型;<c>regionIds</c>/<c>groupIds</c>/<c>locationIds</c> 支持哨兵 <c>ALL</c>。</summary>
12 Task<LabelTypeGetOutputDto> CreateAsync(LabelTypeCreateInputVo input); 13 Task<LabelTypeGetOutputDto> CreateAsync(LabelTypeCreateInputVo input);
13 14
  15 + /// <summary>编辑标签类型;范围传参与新增一致,支持 <c>ALL</c>。</summary>
14 Task<LabelTypeGetOutputDto> UpdateAsync(string id, LabelTypeUpdateInputVo input); 16 Task<LabelTypeGetOutputDto> UpdateAsync(string id, LabelTypeUpdateInputVo input);
15 17
16 Task DeleteAsync(string id); 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,8 +32,10 @@ public interface IProductAppService : IApplicationService
32 /// </summary> 32 /// </summary>
33 /// <remarks> 33 /// <remarks>
34 /// <see cref="ProductCreateInputVo.ProductCode"/> 可选;为空时后端生成唯一编码(如 PRD_ + Guid)。 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 /// </remarks> 39 /// </remarks>
38 Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input); 40 Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input);
39 41
@@ -41,8 +43,8 @@ public interface IProductAppService : IApplicationService @@ -41,8 +43,8 @@ public interface IProductAppService : IApplicationService
41 /// 编辑产品 43 /// 编辑产品
42 /// </summary> 44 /// </summary>
43 /// <remarks> 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 /// </remarks> 48 /// </remarks>
47 Task<ProductGetOutputDto> UpdateAsync(Guid id, ProductUpdateInputVo input); 49 Task<ProductGetOutputDto> UpdateAsync(Guid id, ProductUpdateInputVo input);
48 50
@@ -74,5 +76,36 @@ public interface IProductAppService : IApplicationService @@ -74,5 +76,36 @@ public interface IProductAppService : IApplicationService
74 /// 批量编辑产品(JSON 一次提交多行,与单条 <c>PUT</c> 字段一致) 76 /// 批量编辑产品(JSON 一次提交多行,与单条 <c>PUT</c> 字段一致)
75 /// </summary> 77 /// </summary>
76 Task<ProductBulkUpdateResultDto> UpdateProductsBulkAsync(ProductBulkUpdateInputVo input); 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,12 +17,12 @@ public interface IProductCategoryAppService : IApplicationService
17 Task<ProductCategoryGetOutputDto> GetAsync(string id); 17 Task<ProductCategoryGetOutputDto> GetAsync(string id);
18 18
19 /// <summary> 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 /// </summary> 21 /// </summary>
22 Task<ProductCategoryGetOutputDto> CreateAsync(ProductCategoryCreateInputVo input); 22 Task<ProductCategoryGetOutputDto> CreateAsync(ProductCategoryCreateInputVo input);
23 23
24 /// <summary> 24 /// <summary>
25 - /// 编辑类别;<c>categoryCode</c> 可选;<c>regionIds</c>/<c>locationIds</c> 多选数组规则同新增;传空数组 <c>[]</c> 可清空对应范围 25 + /// 编辑类别;范围传参与新增一致,<c>companyIds</c> 全选回显/保存支持 <c>["ALL"]</c>(PUT)
26 /// </summary> 26 /// </summary>
27 Task<ProductCategoryGetOutputDto> UpdateAsync(string id, ProductCategoryUpdateInputVo input); 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,8 +14,14 @@ public interface ITeamMemberAppService
14 14
15 Task<TeamMemberGetOutputDto> GetAsync(Guid id); 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 Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input); 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 Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input); 25 Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input);
20 26
21 Task DeleteAsync(Guid id); 27 Task DeleteAsync(Guid id);
@@ -39,4 +45,37 @@ public interface ITeamMemberAppService @@ -39,4 +45,37 @@ public interface ITeamMemberAppService
39 /// 批量编辑成员(JSON 一次提交多行) 45 /// 批量编辑成员(JSON 一次提交多行)
40 /// </summary> 46 /// </summary>
41 Task<TeamMemberBulkUpdateResultDto> UpdateTeamMembersBulkAsync(TeamMemberBulkUpdateInputVo input); 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,4 +10,7 @@ public static class UsAppJwtClaims
10 10
11 /// <summary>美国版移动端 App</summary> 11 /// <summary>美国版移动端 App</summary>
12 public const string ClientKindUsApp = "us-app"; 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,10 +4,35 @@
4 <ItemGroup> 4 <ItemGroup>
5 <PackageReference Include="ClosedXML" Version="0.102.3" /> 5 <PackageReference Include="ClosedXML" Version="0.102.3" />
6 <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" /> 6 <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" />
  7 + <PackageReference Include="MySqlConnector" Version="2.4.0" />
7 <PackageReference Include="QuestPDF" Version="2024.12.2" /> 8 <PackageReference Include="QuestPDF" Version="2024.12.2" />
8 </ItemGroup> 9 </ItemGroup>
9 10
10 <ItemGroup> 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 <ProjectReference Include="..\..\..\framework\Yi.Framework.Ddd.Application\Yi.Framework.Ddd.Application.csproj" /> 36 <ProjectReference Include="..\..\..\framework\Yi.Framework.Ddd.Application\Yi.Framework.Ddd.Application.csproj" />
12 <ProjectReference Include="..\..\rbac\Yi.Framework.Rbac.Application.Contracts\Yi.Framework.Rbac.Application.Contracts.csproj" /> 37 <ProjectReference Include="..\..\rbac\Yi.Framework.Rbac.Application.Contracts\Yi.Framework.Rbac.Application.Contracts.csproj" />
13 <ProjectReference Include="..\FoodLabeling.Application.Contracts\FoodLabeling.Application.Contracts.csproj" /> 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,22 +23,36 @@ public static class AllScopeBindingHelper
23 return selected.Count == 0; 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 return false; 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 public static string ResolveDimensionType( 45 public static string ResolveDimensionType(
37 string? declaredType, 46 string? declaredType,
38 IReadOnlyList<string> ids, 47 IReadOnlyList<string> ids,
39 bool hasArrayInPayload, 48 bool hasArrayInPayload,
40 bool isFullSelection) 49 bool isFullSelection)
41 { 50 {
  51 + if (IsDeclaredSpecified(declaredType))
  52 + {
  53 + return ScopeSpecified;
  54 + }
  55 +
42 if (isFullSelection || (ids.Count == 0 && IsDeclaredAll(declaredType))) 56 if (isFullSelection || (ids.Count == 0 && IsDeclaredAll(declaredType)))
43 { 57 {
44 return ScopeAll; 58 return ScopeAll;
@@ -61,6 +75,9 @@ public static class AllScopeBindingHelper @@ -61,6 +75,9 @@ public static class AllScopeBindingHelper
61 public static bool IsDeclaredAll(string? type) => 75 public static bool IsDeclaredAll(string? type) =>
62 string.Equals((type ?? ScopeAll).Trim(), ScopeAll, StringComparison.OrdinalIgnoreCase); 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 /// <summary>全部 Company(<c>fl_partner.Id</c>)。</summary> 81 /// <summary>全部 Company(<c>fl_partner.Id</c>)。</summary>
65 public static async Task<List<string>> ResolveAllPartnerIdsAsync(ISqlSugarClient db) 82 public static async Task<List<string>> ResolveAllPartnerIdsAsync(ISqlSugarClient db)
66 { 83 {
@@ -87,20 +104,51 @@ public static class AllScopeBindingHelper @@ -87,20 +104,51 @@ public static class AllScopeBindingHelper
87 return LocationScopeBindingHelper.NormalizeIds(rows); 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 public static async Task<List<string>> ResolveAllLocationIdsAsync( 112 public static async Task<List<string>> ResolveAllLocationIdsAsync(
92 ISqlSugarClient db, 113 ISqlSugarClient db,
93 IReadOnlyList<string>? partnerIds, 114 IReadOnlyList<string>? partnerIds,
94 IReadOnlyList<string>? regionIds) 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 var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( 140 var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
97 db, 141 db,
98 - partnerIds,  
99 - regionIds, 142 + (IReadOnlyList<string>?)null,
  143 + null,
100 null); 144 null);
101 return LocationScopeBindingHelper.NormalizeIds(merged); 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 /// <summary>Company 维度是否应存为 ALL(含「勾选了全部 Company」)。</summary> 152 /// <summary>Company 维度是否应存为 ALL(含「勾选了全部 Company」)。</summary>
105 public static async Task<(string Type, List<string> Ids)> NormalizePartnerScopeAsync( 153 public static async Task<(string Type, List<string> Ids)> NormalizePartnerScopeAsync(
106 ISqlSugarClient db, 154 ISqlSugarClient db,
@@ -110,6 +158,10 @@ public static class AllScopeBindingHelper @@ -110,6 +158,10 @@ public static class AllScopeBindingHelper
110 bool hasArrayInPayload) 158 bool hasArrayInPayload)
111 { 159 {
112 var ids = LabelEntityPartnerScopeHelper.NormalizePartnerIds(partnerIds, companyIds); 160 var ids = LabelEntityPartnerScopeHelper.NormalizePartnerIds(partnerIds, companyIds);
  161 + if (HasAllScopeSentinelSelection(ids))
  162 + {
  163 + return (ScopeAll, new List<string>());
  164 + }
113 var allPartners = await ResolveAllPartnerIdsAsync(db); 165 var allPartners = await ResolveAllPartnerIdsAsync(db);
114 // 勾选当前全部 Company(含前端仍传 SPECIFIED + 全量 Id)→ 归档 ALL,后续新增 Company 动态适用 166 // 勾选当前全部 Company(含前端仍传 SPECIFIED + 全量 Id)→ 归档 ALL,后续新增 Company 动态适用
115 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allPartners); 167 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allPartners);
@@ -125,7 +177,12 @@ public static class AllScopeBindingHelper @@ -125,7 +177,12 @@ public static class AllScopeBindingHelper
125 bool hasArrayInPayload, 177 bool hasArrayInPayload,
126 IReadOnlyList<string>? partnerIdsForContext) 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 var allRegions = await ResolveAllRegionIdsAsync(db, partnerIdsForContext); 186 var allRegions = await ResolveAllRegionIdsAsync(db, partnerIdsForContext);
130 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allRegions); 187 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allRegions);
131 var type = ResolveDimensionType(declaredType, ids, hasArrayInPayload, isFull); 188 var type = ResolveDimensionType(declaredType, ids, hasArrayInPayload, isFull);
@@ -141,7 +198,12 @@ public static class AllScopeBindingHelper @@ -141,7 +198,12 @@ public static class AllScopeBindingHelper
141 IReadOnlyList<string>? partnerIdsForContext, 198 IReadOnlyList<string>? partnerIdsForContext,
142 IReadOnlyList<string>? regionIdsForContext) 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 var allLocations = await ResolveAllLocationIdsAsync(db, partnerIdsForContext, regionIdsForContext); 207 var allLocations = await ResolveAllLocationIdsAsync(db, partnerIdsForContext, regionIdsForContext);
146 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allLocations); 208 var isFull = ids.Count > 0 && IsFullIdSelection(ids, allLocations);
147 var type = ResolveDimensionType(declaredType, ids, hasArrayInPayload, isFull); 209 var type = ResolveDimensionType(declaredType, ids, hasArrayInPayload, isFull);
@@ -162,7 +224,7 @@ public static class AllScopeBindingHelper @@ -162,7 +224,7 @@ public static class AllScopeBindingHelper
162 return new List<string> { partnerId.Trim() }; 224 return new List<string> { partnerId.Trim() };
163 } 225 }
164 226
165 - var regionIds = LocationScopeBindingHelper.NormalizeIds(regionOrGroupIds); 227 + var regionIds = LocationScopeBindingHelper.FilterConcreteScopeIds(regionOrGroupIds);
166 if (regionIds.Count > 0) 228 if (regionIds.Count > 0)
167 { 229 {
168 var rows = await db.Queryable<FlGroupDbEntity>() 230 var rows = await db.Queryable<FlGroupDbEntity>()
@@ -173,7 +235,7 @@ public static class AllScopeBindingHelper @@ -173,7 +235,7 @@ public static class AllScopeBindingHelper
173 return normalized.Count > 0 ? normalized : null; 235 return normalized.Count > 0 ? normalized : null;
174 } 236 }
175 237
176 - var locIds = LocationScopeBindingHelper.NormalizeIds(locationIds); 238 + var locIds = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds);
177 if (locIds.Count == 0) 239 if (locIds.Count == 0)
178 { 240 {
179 return null; 241 return null;
@@ -195,27 +257,55 @@ public static class AllScopeBindingHelper @@ -195,27 +257,55 @@ public static class AllScopeBindingHelper
195 bool hasScopeArrays, 257 bool hasScopeArrays,
196 IReadOnlyList<string>? partnerIdsForContext) 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 if (IsDeclaredAll(declaredAvailabilityType) 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 return true; 284 return true;
203 } 285 }
204 286
205 if (hasScopeArrays 287 if (hasScopeArrays
206 - && LocationScopeBindingHelper.NormalizeIds(regionIds).Count == 0  
207 - && LocationScopeBindingHelper.NormalizeIds(locationIds).Count == 0 288 + && concreteRegions.Count == 0
  289 + && concreteLocations.Count == 0
208 && IsDeclaredAll(declaredAvailabilityType)) 290 && IsDeclaredAll(declaredAvailabilityType))
209 { 291 {
210 return true; 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 db, 304 db,
216 (IReadOnlyList<string>?)null, 305 (IReadOnlyList<string>?)null,
217 - regionIds,  
218 - locationIds); 306 + concreteRegions,
  307 + concreteLocations);
  308 +
219 if (merged.Count == 0) 309 if (merged.Count == 0)
220 { 310 {
221 return false; 311 return false;
@@ -224,14 +314,14 @@ public static class AllScopeBindingHelper @@ -224,14 +314,14 @@ public static class AllScopeBindingHelper
224 var partnerContext = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext); 314 var partnerContext = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext);
225 if (partnerContext.Count == 0) 315 if (partnerContext.Count == 0)
226 { 316 {
227 - partnerContext = await ResolvePartnerContextFromScopeAsync(db, null, regionIds, locationIds) 317 + partnerContext = await ResolvePartnerContextFromScopeAsync(db, null, concreteRegions, concreteLocations)
228 ?? new List<string>(); 318 ?? new List<string>();
229 } 319 }
230 320
231 var allLocations = await ResolveAllLocationIdsAsync( 321 var allLocations = await ResolveAllLocationIdsAsync(
232 db, 322 db,
233 partnerContext.Count > 0 ? partnerContext : null, 323 partnerContext.Count > 0 ? partnerContext : null,
234 - null); 324 + concreteRegions.Count > 0 ? concreteRegions : null);
235 return IsFullIdSelection(merged, allLocations); 325 return IsFullIdSelection(merged, allLocations);
236 } 326 }
237 327
@@ -244,10 +334,35 @@ public static class AllScopeBindingHelper @@ -244,10 +334,35 @@ public static class AllScopeBindingHelper
244 IReadOnlyList<string>? locationIds, 334 IReadOnlyList<string>? locationIds,
245 bool hasScopePayload) 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 if (IsDeclaredAll(declaredAvailabilityType) 362 if (IsDeclaredAll(declaredAvailabilityType)
248 && string.IsNullOrWhiteSpace(partnerId) 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 return true; 367 return true;
253 } 368 }
@@ -257,20 +372,29 @@ public static class AllScopeBindingHelper @@ -257,20 +372,29 @@ public static class AllScopeBindingHelper
257 return false; 372 return false;
258 } 373 }
259 374
  375 + // 有具体门店时不升全局 ALL
  376 + if (concreteLocations.Count > 0)
  377 + {
  378 + return false;
  379 + }
  380 +
260 var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( 381 var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
261 db, 382 db,
262 partnerId, 383 partnerId,
263 - groupIds,  
264 - locationIds); 384 + concreteGroups,
  385 + concreteLocations);
  386 +
265 if (merged.Count == 0) 387 if (merged.Count == 0)
266 { 388 {
267 return false; 389 return false;
268 } 390 }
269 391
270 - // 未传 partnerId 时从 Region/门店反推 Company,避免用「全系统门店」误判导致无法归档 ALL  
271 var partnerContext = await ResolvePartnerContextFromScopeAsync( 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 return IsFullIdSelection(merged, allLocations); 398 return IsFullIdSelection(merged, allLocations);
275 } 399 }
276 400
@@ -302,4 +426,169 @@ public static class AllScopeBindingHelper @@ -302,4 +426,169 @@ public static class AllScopeBindingHelper
302 : await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, locs); 426 : await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, locs);
303 return (partnerIds, regionIds, locs); 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,6 +11,10 @@ public static class EntityLocationScopeDisplayHelper
11 /// <summary> 11 /// <summary>
12 /// 根据可用范围类型与已解析的 Region/Location Id、名称,生成列表展示文案。 12 /// 根据可用范围类型与已解析的 Region/Location Id、名称,生成列表展示文案。
13 /// </summary> 13 /// </summary>
  14 + /// <remarks>
  15 + /// 有具体门店 Guid 时始终展示门店名称,不因「覆盖某 Region 下全部门店」误显示 All Location
  16 + /// (常见于 <c>AppliedRegionType=ALL</c> + 单个 <c>locationIds</c>)。
  17 + /// </remarks>
14 public static async Task<(string Region, string Location)> BuildListDisplayAsync( 18 public static async Task<(string Region, string Location)> BuildListDisplayAsync(
15 ISqlSugarClient db, 19 ISqlSugarClient db,
16 string? availabilityType, 20 string? availabilityType,
@@ -18,7 +22,8 @@ public static class EntityLocationScopeDisplayHelper @@ -18,7 +22,8 @@ public static class EntityLocationScopeDisplayHelper
18 IReadOnlyList<string> locationIds, 22 IReadOnlyList<string> locationIds,
19 IEnumerable<string> regionNames, 23 IEnumerable<string> regionNames,
20 IEnumerable<string> locationNames, 24 IEnumerable<string> locationNames,
21 - IReadOnlyList<string>? partnerIdsForContext) 25 + IReadOnlyList<string>? partnerIdsForContext,
  26 + string? appliedRegionType = null)
22 { 27 {
23 if (AllScopeBindingHelper.IsDeclaredAll(availabilityType)) 28 if (AllScopeBindingHelper.IsDeclaredAll(availabilityType))
24 { 29 {
@@ -29,27 +34,33 @@ public static class EntityLocationScopeDisplayHelper @@ -29,27 +34,33 @@ public static class EntityLocationScopeDisplayHelper
29 var normLocationIds = LocationScopeBindingHelper.NormalizeIds(locationIds); 34 var normLocationIds = LocationScopeBindingHelper.NormalizeIds(locationIds);
30 var partnerContext = partnerIdsForContext is { Count: > 0 } ? partnerIdsForContext : null; 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 return (regionDisplay, locationDisplay); 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,12 +4,37 @@ using SqlSugar;
4 namespace FoodLabeling.Application.Helpers; 4 namespace FoodLabeling.Application.Helpers;
5 5
6 /// <summary> 6 /// <summary>
7 -/// 标签类型/分类/多选项列表:非平台管理员仅可见其绑定 Company 范围内数据。 7 +/// 标签类型/分类/多选项列表:按可见门店范围过滤。
  8 +/// AvailabilityType=ALL 时传任意 Region/Location 筛选均命中。
8 /// </summary> 9 /// </summary>
9 public static class LabelEntityListScopeHelper 10 public static class LabelEntityListScopeHelper
10 { 11 {
11 /// <summary> 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 /// </summary> 38 /// </summary>
14 public static ISugarQueryable<FlLabelTypeDbEntity> ApplyTypeLocationAvailabilityFilter( 39 public static ISugarQueryable<FlLabelTypeDbEntity> ApplyTypeLocationAvailabilityFilter(
15 ISugarQueryable<FlLabelTypeDbEntity> query, 40 ISugarQueryable<FlLabelTypeDbEntity> query,
@@ -22,12 +47,12 @@ public static class LabelEntityListScopeHelper @@ -22,12 +47,12 @@ public static class LabelEntityListScopeHelper
22 47
23 if (scopedLocationIds.Count == 0) 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 return query.Where(t => 54 return query.Where(t =>
29 - (t.AvailabilityType == AllScopeBindingHelper.ScopeAll  
30 - && t.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified) 55 + t.AvailabilityType == AllScopeBindingHelper.ScopeAll
31 || (t.AvailabilityType == AllScopeBindingHelper.ScopeSpecified 56 || (t.AvailabilityType == AllScopeBindingHelper.ScopeSpecified
32 && SqlFunc.Subqueryable<FlLabelTypeLocationDbEntity>() 57 && SqlFunc.Subqueryable<FlLabelTypeLocationDbEntity>()
33 .Where(tl => tl.LabelTypeId == t.Id && scopedLocationIds.Contains(tl.LocationId)) 58 .Where(tl => tl.LabelTypeId == t.Id && scopedLocationIds.Contains(tl.LocationId))
@@ -35,7 +60,7 @@ public static class LabelEntityListScopeHelper @@ -35,7 +60,7 @@ public static class LabelEntityListScopeHelper
35 } 60 }
36 61
37 /// <summary> 62 /// <summary>
38 - /// 标签分类列表:按 Company + Region/Location 范围过滤 63 + /// 标签分类列表:门店可用范围过滤(ALL 命中任意门店筛选)
39 /// </summary> 64 /// </summary>
40 public static ISugarQueryable<FlLabelCategoryDbEntity> ApplyCategoryLocationAvailabilityFilter( 65 public static ISugarQueryable<FlLabelCategoryDbEntity> ApplyCategoryLocationAvailabilityFilter(
41 ISugarQueryable<FlLabelCategoryDbEntity> query, 66 ISugarQueryable<FlLabelCategoryDbEntity> query,
@@ -48,12 +73,11 @@ public static class LabelEntityListScopeHelper @@ -48,12 +73,11 @@ public static class LabelEntityListScopeHelper
48 73
49 if (scopedLocationIds.Count == 0) 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 return query.Where(c => 79 return query.Where(c =>
55 - (c.AvailabilityType == AllScopeBindingHelper.ScopeAll  
56 - && c.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified) 80 + c.AvailabilityType == AllScopeBindingHelper.ScopeAll
57 || (c.AvailabilityType == AllScopeBindingHelper.ScopeSpecified 81 || (c.AvailabilityType == AllScopeBindingHelper.ScopeSpecified
58 && SqlFunc.Subqueryable<FlLabelCategoryLocationDbEntity>() 82 && SqlFunc.Subqueryable<FlLabelCategoryLocationDbEntity>()
59 .Where(cl => cl.CategoryId == c.Id && scopedLocationIds.Contains(cl.LocationId)) 83 .Where(cl => cl.CategoryId == c.Id && scopedLocationIds.Contains(cl.LocationId))
@@ -61,7 +85,7 @@ public static class LabelEntityListScopeHelper @@ -61,7 +85,7 @@ public static class LabelEntityListScopeHelper
61 } 85 }
62 86
63 /// <summary> 87 /// <summary>
64 - /// 标签多选项列表:按 Company + Region/Location 范围过滤 88 + /// 标签多选项列表:门店可用范围过滤(ALL 命中任意门店筛选)
65 /// </summary> 89 /// </summary>
66 public static ISugarQueryable<FlLabelMultipleOptionDbEntity> ApplyMultipleOptionLocationAvailabilityFilter( 90 public static ISugarQueryable<FlLabelMultipleOptionDbEntity> ApplyMultipleOptionLocationAvailabilityFilter(
67 ISugarQueryable<FlLabelMultipleOptionDbEntity> query, 91 ISugarQueryable<FlLabelMultipleOptionDbEntity> query,
@@ -74,12 +98,11 @@ public static class LabelEntityListScopeHelper @@ -74,12 +98,11 @@ public static class LabelEntityListScopeHelper
74 98
75 if (scopedLocationIds.Count == 0) 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 return query.Where(o => 104 return query.Where(o =>
81 - (o.AvailabilityType == AllScopeBindingHelper.ScopeAll  
82 - && o.AppliedPartnerType == LabelEntityPartnerScopeHelper.ScopeSpecified) 105 + o.AvailabilityType == AllScopeBindingHelper.ScopeAll
83 || (o.AvailabilityType == AllScopeBindingHelper.ScopeSpecified 106 || (o.AvailabilityType == AllScopeBindingHelper.ScopeSpecified
84 && SqlFunc.Subqueryable<FlLabelMultipleOptionLocationDbEntity>() 107 && SqlFunc.Subqueryable<FlLabelMultipleOptionLocationDbEntity>()
85 .Where(ol => ol.MultipleOptionId == o.Id && scopedLocationIds.Contains(ol.LocationId)) 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,7 +26,10 @@ public static class LabelEntityPartnerScopeHelper
26 Category, 26 Category,
27 27
28 /// <summary>标签多选项 <c>fl_label_multiple_option</c></summary> 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 public sealed class LabelEntityPartnerScopeSaveResult 35 public sealed class LabelEntityPartnerScopeSaveResult
@@ -60,6 +63,10 @@ public static class LabelEntityPartnerScopeHelper @@ -60,6 +63,10 @@ public static class LabelEntityPartnerScopeHelper
60 public bool HasCategoryPartnerTable { get; init; } 63 public bool HasCategoryPartnerTable { get; init; }
61 64
62 public bool HasMultipleOptionPartnerTable { get; init; } 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 /// <summary> 72 /// <summary>
@@ -185,7 +192,7 @@ public static class LabelEntityPartnerScopeHelper @@ -185,7 +192,7 @@ public static class LabelEntityPartnerScopeHelper
185 if (needsPartnerRows && !hasTable) 192 if (needsPartnerRows && !hasTable)
186 { 193 {
187 throw new UserFriendlyException( 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 if (hasTable) 198 if (hasTable)
@@ -325,11 +332,30 @@ public static class LabelEntityPartnerScopeHelper @@ -325,11 +332,30 @@ public static class LabelEntityPartnerScopeHelper
325 ? AllCompaniesDisplay 332 ? AllCompaniesDisplay
326 : BuildCompanyDisplay(pIds, partnerNameById); 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 result[entityId] = new LabelEntityPartnerScopeDisplay 354 result[entityId] = new LabelEntityPartnerScopeDisplay
329 { 355 {
330 Company = companyDisplay, 356 Company = companyDisplay,
331 AppliedPartnerType = partnerType, 357 AppliedPartnerType = partnerType,
332 - PartnerIds = pIds 358 + PartnerIds = displayPartnerIds
333 }; 359 };
334 } 360 }
335 361
@@ -383,11 +409,13 @@ public static class LabelEntityPartnerScopeHelper @@ -383,11 +409,13 @@ public static class LabelEntityPartnerScopeHelper
383 return query.Where(_ => false); 409 return query.Where(_ => false);
384 } 410 }
385 411
  412 + // Company=ALL:传任意 partnerId 均命中;SPECIFIED:须与关联表有交集
386 return query.Where(t => 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 /// <summary> 421 /// <summary>
@@ -415,10 +443,11 @@ public static class LabelEntityPartnerScopeHelper @@ -415,10 +443,11 @@ public static class LabelEntityPartnerScopeHelper
415 } 443 }
416 444
417 return query.Where(c => 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 /// <summary> 453 /// <summary>
@@ -446,14 +475,48 @@ public static class LabelEntityPartnerScopeHelper @@ -446,14 +475,48 @@ public static class LabelEntityPartnerScopeHelper
446 } 475 }
447 476
448 return query.Where(o => 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 /// <summary> 517 /// <summary>
456 /// 由 Query <c>partnerId</c> 解析列表 Company 筛选 Id;未传则返回 <c>null</c>。 518 /// 由 Query <c>partnerId</c> 解析列表 Company 筛选 Id;未传则返回 <c>null</c>。
  519 + /// Id 统一为小写 Guid,避免与关联表大小写不一致导致 IN 匹配失败。
457 /// </summary> 520 /// </summary>
458 public static async Task<List<string>?> ResolveScopedPartnerIdsForListAsync( 521 public static async Task<List<string>?> ResolveScopedPartnerIdsForListAsync(
459 ISqlSugarClient db, 522 ISqlSugarClient db,
@@ -467,7 +530,13 @@ public static class LabelEntityPartnerScopeHelper @@ -467,7 +530,13 @@ public static class LabelEntityPartnerScopeHelper
467 530
468 var exists = await db.Queryable<FlPartnerDbEntity>() 531 var exists = await db.Queryable<FlPartnerDbEntity>()
469 .AnyAsync(x => !x.IsDeleted && x.Id == pid); 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 /// <summary> 542 /// <summary>
@@ -514,6 +583,11 @@ public static class LabelEntityPartnerScopeHelper @@ -514,6 +583,11 @@ public static class LabelEntityPartnerScopeHelper
514 .Where(x => x.MultipleOptionId == entityId) 583 .Where(x => x.MultipleOptionId == entityId)
515 .ExecuteCommandAsync(); 584 .ExecuteCommandAsync();
516 break; 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,7 +638,10 @@ public static class LabelEntityPartnerScopeHelper
564 db, "fl_label_multiple_option", "AppliedPartnerType"), 638 db, "fl_label_multiple_option", "AppliedPartnerType"),
565 HasTypePartnerTable = await TableExistsAsync(db, "fl_label_type_partner"), 639 HasTypePartnerTable = await TableExistsAsync(db, "fl_label_type_partner"),
566 HasCategoryPartnerTable = await TableExistsAsync(db, "fl_label_category_partner"), 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 catch 647 catch
@@ -581,6 +658,7 @@ public static class LabelEntityPartnerScopeHelper @@ -581,6 +658,7 @@ public static class LabelEntityPartnerScopeHelper
581 LabelEntityPartnerKind.Type => schema.HasTypePartnerTable, 658 LabelEntityPartnerKind.Type => schema.HasTypePartnerTable,
582 LabelEntityPartnerKind.Category => schema.HasCategoryPartnerTable, 659 LabelEntityPartnerKind.Category => schema.HasCategoryPartnerTable,
583 LabelEntityPartnerKind.MultipleOption => schema.HasMultipleOptionPartnerTable, 660 LabelEntityPartnerKind.MultipleOption => schema.HasMultipleOptionPartnerTable,
  661 + LabelEntityPartnerKind.ProductCategory => schema.HasProductCategoryPartnerTable,
584 _ => false 662 _ => false
585 }; 663 };
586 664
@@ -590,6 +668,7 @@ public static class LabelEntityPartnerScopeHelper @@ -590,6 +668,7 @@ public static class LabelEntityPartnerScopeHelper
590 LabelEntityPartnerKind.Type => schema.HasTypePartnerColumn, 668 LabelEntityPartnerKind.Type => schema.HasTypePartnerColumn,
591 LabelEntityPartnerKind.Category => schema.HasCategoryPartnerColumn, 669 LabelEntityPartnerKind.Category => schema.HasCategoryPartnerColumn,
592 LabelEntityPartnerKind.MultipleOption => schema.HasMultipleOptionPartnerColumn, 670 LabelEntityPartnerKind.MultipleOption => schema.HasMultipleOptionPartnerColumn,
  671 + LabelEntityPartnerKind.ProductCategory => schema.HasProductCategoryPartnerColumn,
593 _ => false 672 _ => false
594 }; 673 };
595 674
@@ -637,6 +716,7 @@ public static class LabelEntityPartnerScopeHelper @@ -637,6 +716,7 @@ public static class LabelEntityPartnerScopeHelper
637 LabelEntityPartnerKind.Type => "fl_label_type", 716 LabelEntityPartnerKind.Type => "fl_label_type",
638 LabelEntityPartnerKind.Category => "fl_label_category", 717 LabelEntityPartnerKind.Category => "fl_label_category",
639 LabelEntityPartnerKind.MultipleOption => "fl_label_multiple_option", 718 LabelEntityPartnerKind.MultipleOption => "fl_label_multiple_option",
  719 + LabelEntityPartnerKind.ProductCategory => "fl_product_category",
640 _ => string.Empty 720 _ => string.Empty
641 }; 721 };
642 722
@@ -666,6 +746,7 @@ public static class LabelEntityPartnerScopeHelper @@ -666,6 +746,7 @@ public static class LabelEntityPartnerScopeHelper
666 LabelEntityPartnerKind.Type => "fl_label_type", 746 LabelEntityPartnerKind.Type => "fl_label_type",
667 LabelEntityPartnerKind.Category => "fl_label_category", 747 LabelEntityPartnerKind.Category => "fl_label_category",
668 LabelEntityPartnerKind.MultipleOption => "fl_label_multiple_option", 748 LabelEntityPartnerKind.MultipleOption => "fl_label_multiple_option",
  749 + LabelEntityPartnerKind.ProductCategory => "fl_product_category",
669 _ => string.Empty 750 _ => string.Empty
670 }; 751 };
671 752
@@ -709,6 +790,13 @@ public static class LabelEntityPartnerScopeHelper @@ -709,6 +790,13 @@ public static class LabelEntityPartnerScopeHelper
709 .ToListAsync(); 790 .ToListAsync();
710 return rows.Select(x => (x.MultipleOptionId, x.PartnerId)).ToList(); 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 default: 800 default:
713 return new List<(string EntityId, string PartnerId)>(); 801 return new List<(string EntityId, string PartnerId)>();
714 } 802 }
@@ -764,6 +852,19 @@ public static class LabelEntityPartnerScopeHelper @@ -764,6 +852,19 @@ public static class LabelEntityPartnerScopeHelper
764 await db.Insertable(rows).ExecuteCommandAsync(); 852 await db.Insertable(rows).ExecuteCommandAsync();
765 break; 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,6 +28,7 @@ public static class LabelQueryHelper
28 LabelName = x.LabelName, 28 LabelName = x.LabelName,
29 TemplateId = x.TemplateId, 29 TemplateId = x.TemplateId,
30 LocationId = x.LocationId, 30 LocationId = x.LocationId,
  31 + PartnerId = x.PartnerId,
31 LabelCategoryId = x.LabelCategoryId, 32 LabelCategoryId = x.LabelCategoryId,
32 LabelTypeId = x.LabelTypeId, 33 LabelTypeId = x.LabelTypeId,
33 LabelType = x.LabelType, 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,12 +58,40 @@ public static class LabelRegionScopeHelper
58 IReadOnlyList<string>? regionIds, 58 IReadOnlyList<string>? regionIds,
59 IReadOnlyList<string>? groupIds, 59 IReadOnlyList<string>? groupIds,
60 string? locationId, 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 var type = (appliedRegionType ?? AppliedRegionAll).Trim().ToUpperInvariant(); 70 var type = (appliedRegionType ?? AppliedRegionAll).Trim().ToUpperInvariant();
66 var hasScopeArrays = regionIds is not null || groupIds is not null || locationIds is not null; 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 if (mergedRegionIds.Count > 0) 96 if (mergedRegionIds.Count > 0)
69 { 97 {
@@ -95,8 +123,10 @@ public static class LabelRegionScopeHelper @@ -95,8 +123,10 @@ public static class LabelRegionScopeHelper
95 await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(db, explicitLocationIds); 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 var regionsFull = mergedRegionIds.Count > 0 130 var regionsFull = mergedRegionIds.Count > 0
101 && AllScopeBindingHelper.IsFullIdSelection(mergedRegionIds, allRegions); 131 && AllScopeBindingHelper.IsFullIdSelection(mergedRegionIds, allRegions);
102 132
@@ -107,7 +137,7 @@ public static class LabelRegionScopeHelper @@ -107,7 +137,7 @@ public static class LabelRegionScopeHelper
107 if (declaredOrFullAll) 137 if (declaredOrFullAll)
108 { 138 {
109 var allLocationsForPartner = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( 139 var allLocationsForPartner = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(
110 - db, partnerContext, null); 140 + db, resolvedPartnerContext, null);
111 var locationsFullForPartner = explicitLocationIds.Count == 0 141 var locationsFullForPartner = explicitLocationIds.Count == 0
112 || (allLocationsForPartner.Count > 0 142 || (allLocationsForPartner.Count > 0
113 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocationsForPartner)); 143 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocationsForPartner));
@@ -135,7 +165,7 @@ public static class LabelRegionScopeHelper @@ -135,7 +165,7 @@ public static class LabelRegionScopeHelper
135 if (mergedRegionIds.Count == 0 && explicitLocationIds.Count > 0) 165 if (mergedRegionIds.Count == 0 && explicitLocationIds.Count > 0)
136 { 166 {
137 var allLocations = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( 167 var allLocations = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(
138 - db, partnerContext, null); 168 + db, resolvedPartnerContext, null);
139 if (allLocations.Count > 0 169 if (allLocations.Count > 0
140 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocations)) 170 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocations))
141 { 171 {
@@ -157,7 +187,7 @@ public static class LabelRegionScopeHelper @@ -157,7 +187,7 @@ public static class LabelRegionScopeHelper
157 187
158 // 部分 Region:门店 Select All(空或覆盖该区域全集)→ 只落 Region,不写门店快照(新区店靠 Region 动态匹配) 188 // 部分 Region:门店 Select All(空或覆盖该区域全集)→ 只落 Region,不写门店快照(新区店靠 Region 动态匹配)
159 var allLocationsInRegions = await AllScopeBindingHelper.ResolveAllLocationIdsAsync( 189 var allLocationsInRegions = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(
160 - db, null, mergedRegionIds); 190 + db, resolvedPartnerContext, mergedRegionIds);
161 var locationsFullInRegions = explicitLocationIds.Count == 0 191 var locationsFullInRegions = explicitLocationIds.Count == 0
162 || (allLocationsInRegions.Count > 0 192 || (allLocationsInRegions.Count > 0
163 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocationsInRegions)); 193 && AllScopeBindingHelper.IsFullIdSelection(explicitLocationIds, allLocationsInRegions));
@@ -179,7 +209,7 @@ public static class LabelRegionScopeHelper @@ -179,7 +209,7 @@ public static class LabelRegionScopeHelper
179 209
180 // 部分 Region + 部分门店 → 校验门店属于所选 Region 后落快照 210 // 部分 Region + 部分门店 → 校验门店属于所选 Region 后落快照
181 var mergedLocations = await LocationScopeBindingHelper.MergeToLocationIdsAsync( 211 var mergedLocations = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
182 - db, (IReadOnlyList<string>?)null, mergedRegionIds, explicitLocationIds); 212 + db, resolvedPartnerContext, mergedRegionIds, explicitLocationIds);
183 if (mergedLocations.Count == 0) 213 if (mergedLocations.Count == 0)
184 { 214 {
185 throw new UserFriendlyException("指定 Region 下未匹配到有效门店,请检查 Region 或 locationIds"); 215 throw new UserFriendlyException("指定 Region 下未匹配到有效门店,请检查 Region 或 locationIds");
@@ -405,8 +435,8 @@ public static class LabelRegionScopeHelper @@ -405,8 +435,8 @@ public static class LabelRegionScopeHelper
405 return new LabelRegionScopeDisplay 435 return new LabelRegionScopeDisplay
406 { 436 {
407 Region = AllRegionsDisplay, 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,11 +465,18 @@ public static class LabelRegionScopeHelper
435 ? string.Join(", ", names.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!.Trim()).Distinct()) 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 return new LabelRegionScopeDisplay 475 return new LabelRegionScopeDisplay
439 { 476 {
440 Region = regionText, 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,7 +491,7 @@ public static class LabelRegionScopeHelper
454 return new LabelLocationScopeDisplay 491 return new LabelLocationScopeDisplay
455 { 492 {
456 Location = AllLocationsDisplay, 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,10 +519,17 @@ public static class LabelRegionScopeHelper
482 ? string.Join(", ", names.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!.Trim()).Distinct()) 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 return new LabelLocationScopeDisplay 529 return new LabelLocationScopeDisplay
486 { 530 {
487 Location = locationText, 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,7 +637,24 @@ public static class LabelRegionScopeHelper
593 new { all = AppliedRegionAll, gid }); 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 public static ISugarQueryable<FlLabelDbEntity> ApplyLabelLocationListFilter( 658 public static ISugarQueryable<FlLabelDbEntity> ApplyLabelLocationListFilter(
598 ISqlSugarClient db, 659 ISqlSugarClient db,
599 ISugarQueryable<FlLabelDbEntity> query, 660 ISugarQueryable<FlLabelDbEntity> query,
@@ -602,7 +663,7 @@ public static class LabelRegionScopeHelper @@ -602,7 +663,7 @@ public static class LabelRegionScopeHelper
602 { 663 {
603 if (scopedLocationIds.Count == 0) 664 if (scopedLocationIds.Count == 0)
604 { 665 {
605 - return query.Where(_ => false); 666 + return ApplyLabelAllRegionOnlyFilter(db, query, schema);
606 } 667 }
607 668
608 return ApplyLabelLocationMatchFilter(query, scopedLocationIds, schema); 669 return ApplyLabelLocationMatchFilter(query, scopedLocationIds, schema);
@@ -648,6 +709,14 @@ public static class LabelRegionScopeHelper @@ -648,6 +709,14 @@ public static class LabelRegionScopeHelper
648 var locationIds = await GetLocationIdsForLabelAsync(db, label.Id, label.LocationId); 709 var locationIds = await GetLocationIdsForLabelAsync(db, label.Id, label.LocationId);
649 if (locationIds.Count == 0) 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 return true; 720 return true;
652 } 721 }
653 722
@@ -784,6 +853,26 @@ public static class LabelRegionScopeHelper @@ -784,6 +853,26 @@ public static class LabelRegionScopeHelper
784 List<string> scopedLocationIds, 853 List<string> scopedLocationIds,
785 LabelRegionSchemaHelper.LabelRegionSchemaStatus schema) 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 if (schema.HasLabelLocationTable) 876 if (schema.HasLabelLocationTable)
788 { 877 {
789 return query.Where( 878 return query.Where(
@@ -829,17 +918,18 @@ public static class LabelRegionScopeHelper @@ -829,17 +918,18 @@ public static class LabelRegionScopeHelper
829 918
830 private static async Task ValidateRegionIdsExistAsync(ISqlSugarClient db, List<string> regionIds) 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 return; 924 return;
835 } 925 }
836 926
837 var count = await db.Queryable<FlGroupDbEntity>() 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 .CountAsync(); 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,43 +58,73 @@ public static class LabelTemplateScopeHelper
58 58
59 /// <summary> 59 /// <summary>
60 /// 解析新增/编辑入参中的 Company / Region / Location 范围。 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 /// </summary> 63 /// </summary>
62 public static async Task<LabelTemplateScopeSaveResult> ResolveScopeForSaveAsync( 64 public static async Task<LabelTemplateScopeSaveResult> ResolveScopeForSaveAsync(
63 ISqlSugarClient db, 65 ISqlSugarClient db,
64 LabelTemplateCreateInputVo input) 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 var (partnerType, partnerIdsForSave) = await AllScopeBindingHelper.NormalizePartnerScopeAsync( 78 var (partnerType, partnerIdsForSave) = await AllScopeBindingHelper.NormalizePartnerScopeAsync(
71 db, 79 db,
72 input.AppliedPartnerType, 80 input.AppliedPartnerType,
73 - partnerIds,  
74 - null, 81 + input.PartnerIds,
  82 + input.CompanyIds,
75 input.PartnerIds is not null || input.CompanyIds is not null); 83 input.PartnerIds is not null || input.CompanyIds is not null);
76 - partnerIds = partnerIdsForSave; 84 + var partnerIds = partnerIdsForSave;
77 85
78 var partnerContext = string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) 86 var partnerContext = string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
79 ? partnerIds 87 ? partnerIds
80 : null; 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 ValidateDimensionType("Company", partnerType); 129 ValidateDimensionType("Company", partnerType);
100 ValidateDimensionType("Region", regionType); 130 ValidateDimensionType("Region", regionType);
@@ -134,7 +164,9 @@ public static class LabelTemplateScopeHelper @@ -134,7 +164,9 @@ public static class LabelTemplateScopeHelper
134 || string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase) 164 || string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
135 || string.Equals(locationType, ScopeSpecified, StringComparison.OrdinalIgnoreCase); 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 var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( 171 var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
140 db, 172 db,
@@ -300,6 +332,8 @@ public static class LabelTemplateScopeHelper @@ -300,6 +332,8 @@ public static class LabelTemplateScopeHelper
300 .Where(x => templateIds.Contains(x.TemplateId)) 332 .Where(x => templateIds.Contains(x.TemplateId))
301 .ToListAsync(); 333 .ToListAsync();
302 334
  335 + var storedScopeTypes = await LabelTemplateScopeSchemaHelper.GetAppliedScopeTypesMapAsync(db, templateIds);
  336 +
303 var partnerIdSet = partnerLinks.Select(x => x.PartnerId).Distinct(StringComparer.Ordinal).ToList(); 337 var partnerIdSet = partnerLinks.Select(x => x.PartnerId).Distinct(StringComparer.Ordinal).ToList();
304 var regionIdSet = regionLinks.Select(x => x.GroupId).Distinct(StringComparer.Ordinal).ToList(); 338 var regionIdSet = regionLinks.Select(x => x.GroupId).Distinct(StringComparer.Ordinal).ToList();
305 var locationIdSet = locationLinks.Select(x => x.LocationId).Distinct(StringComparer.Ordinal).ToList(); 339 var locationIdSet = locationLinks.Select(x => x.LocationId).Distinct(StringComparer.Ordinal).ToList();
@@ -360,9 +394,16 @@ public static class LabelTemplateScopeHelper @@ -360,9 +394,16 @@ public static class LabelTemplateScopeHelper
360 var partnerType = pIds.Count > 0 ? ScopeSpecified : ScopeAll; 394 var partnerType = pIds.Count > 0 ? ScopeSpecified : ScopeAll;
361 var regionType = rIds.Count > 0 ? ScopeSpecified : ScopeAll; 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 if (hasExtendedScope 403 if (hasExtendedScope
364 && pIds.Count == 0 404 && pIds.Count == 0
365 - && lIds.Count > 0) 405 + && lIds.Count > 0
  406 + && !string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))
366 { 407 {
367 pIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, lIds); 408 pIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, lIds);
368 if (pIds.Count > 0) 409 if (pIds.Count > 0)
@@ -373,7 +414,8 @@ public static class LabelTemplateScopeHelper @@ -373,7 +414,8 @@ public static class LabelTemplateScopeHelper
373 414
374 if (hasExtendedScope 415 if (hasExtendedScope
375 && rIds.Count == 0 416 && rIds.Count == 0
376 - && lIds.Count > 0) 417 + && lIds.Count > 0
  418 + && !string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))
377 { 419 {
378 rIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(db, lIds); 420 rIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(db, lIds);
379 if (rIds.Count > 0) 421 if (rIds.Count > 0)
@@ -401,6 +443,13 @@ public static class LabelTemplateScopeHelper @@ -401,6 +443,13 @@ public static class LabelTemplateScopeHelper
401 ? AllLocationsDisplay 443 ? AllLocationsDisplay
402 : FormatLocationNames(lIds, locById); 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 result[template.Id] = new LabelTemplateScopeDisplay 453 result[template.Id] = new LabelTemplateScopeDisplay
405 { 454 {
406 Company = companyDisplay, 455 Company = companyDisplay,
@@ -409,9 +458,9 @@ public static class LabelTemplateScopeHelper @@ -409,9 +458,9 @@ public static class LabelTemplateScopeHelper
409 AppliedPartnerType = partnerType, 458 AppliedPartnerType = partnerType,
410 AppliedRegionType = regionType, 459 AppliedRegionType = regionType,
411 AppliedLocationType = locationType, 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,31 +468,58 @@ public static class LabelTemplateScopeHelper
419 } 468 }
420 469
421 /// <summary> 470 /// <summary>
422 - /// 列表权限:按当前用户可见门店筛选模板(各维度 AND)。  
423 - /// <paramref name="scopedLocationIds"/> 为 <c>null</c> 时不限制(管理员且未传 Query 筛选);  
424 - /// 非空时仅返回与可见 Company/Region/Location 匹配的模板,不包含未绑定任何范围的「全局」模板。 471 + /// 列表筛选:按当前用户可见范围筛选模板。
  472 + /// 仅传 <c>partnerId</c> 时只按 Company 维度过滤(多公司绑定不会被门店 AND 误杀)。
425 /// </summary> 473 /// </summary>
426 public static async Task<ISugarQueryable<FlLabelTemplateDbEntity>> ApplyTemplateScopeFilterAsync( 474 public static async Task<ISugarQueryable<FlLabelTemplateDbEntity>> ApplyTemplateScopeFilterAsync(
427 ISqlSugarClient db, 475 ISqlSugarClient db,
428 ISugarQueryable<FlLabelTemplateDbEntity> query, 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 if (scopedLocationIds is null) 503 if (scopedLocationIds is null)
432 { 504 {
433 return query; 505 return query;
434 } 506 }
435 507
436 - var hasExtendedScope = await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(db);  
437 -  
438 if (scopedLocationIds.Count == 0) 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 if (!hasExtendedScope) 518 if (!hasExtendedScope)
444 { 519 {
445 return query.Where(t => 520 return query.Where(t =>
446 - SqlFunc.Subqueryable<FlLabelTemplateLocationDbEntity>() 521 + t.AppliedLocationType == ScopeAll
  522 + || SqlFunc.Subqueryable<FlLabelTemplateLocationDbEntity>()
447 .Where(l => l.TemplateId == t.Id && scopedLocationIds.Contains(l.LocationId)) 523 .Where(l => l.TemplateId == t.Id && scopedLocationIds.Contains(l.LocationId))
448 .Any()); 524 .Any());
449 } 525 }
@@ -453,6 +529,30 @@ public static class LabelTemplateScopeHelper @@ -453,6 +529,30 @@ public static class LabelTemplateScopeHelper
453 var scopedGroupIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( 529 var scopedGroupIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
454 db, scopedLocationIds); 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 return query.Where(t => 556 return query.Where(t =>
457 SqlFunc.Subqueryable<FlLabelTemplatePartnerDbEntity>() 557 SqlFunc.Subqueryable<FlLabelTemplatePartnerDbEntity>()
458 .Where(p => p.TemplateId == t.Id && scopedPartnerIds.Contains(p.PartnerId)) 558 .Where(p => p.TemplateId == t.Id && scopedPartnerIds.Contains(p.PartnerId))
@@ -567,15 +667,16 @@ public static class LabelTemplateScopeHelper @@ -567,15 +667,16 @@ public static class LabelTemplateScopeHelper
567 667
568 private static async Task ValidateRegionIdsExistAsync(ISqlSugarClient db, List<string> regionIds) 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 return; 673 return;
573 } 674 }
574 675
575 var count = await db.Queryable<FlGroupDbEntity>() 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 .CountAsync(); 678 .CountAsync();
578 - if (count != regionIds.Count) 679 + if (count != ids.Count)
579 { 680 {
580 throw new UserFriendlyException("存在无效的 Region(regionIds/groupIds),请刷新后重试"); 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,6 +315,77 @@ public static class LabelTemplateScopeSchemaHelper
315 return map.TryGetValue(templateId.Trim(), out var text) ? text : null; 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 /// <summary>已迁移 <c>Contents</c> 列时写入(未迁移则 no-op)。</summary> 389 /// <summary>已迁移 <c>Contents</c> 列时写入(未迁移则 no-op)。</summary>
319 public static async Task SetContentsAsync(ISqlSugarClient db, string templateId, string? contents) 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,13 +306,14 @@ public static class LocationScopeBindingHelper
306 merged.Add(id); 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 foreach (var id in fromGroups) 311 foreach (var id in fromGroups)
311 { 312 {
312 merged.Add(id); 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 merged.Add(id); 318 merged.Add(id);
318 } 319 }
@@ -321,6 +322,113 @@ public static class LocationScopeBindingHelper @@ -321,6 +322,113 @@ public static class LocationScopeBindingHelper
321 } 322 }
322 323
323 /// <summary> 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 /// 标签类型/分类/多选项门店范围落库:仅按 Region/Location 合并,不因 Company(partner)展开并集。 432 /// 标签类型/分类/多选项门店范围落库:仅按 Region/Location 合并,不因 Company(partner)展开并集。
325 /// </summary> 433 /// </summary>
326 public static async Task<List<string>> ResolveEntityLocationIdsForSaveAsync( 434 public static async Task<List<string>> ResolveEntityLocationIdsForSaveAsync(
@@ -334,11 +442,28 @@ public static class LocationScopeBindingHelper @@ -334,11 +442,28 @@ public static class LocationScopeBindingHelper
334 return new List<string>(); 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 var merged = await MergeToLocationIdsAsync( 462 var merged = await MergeToLocationIdsAsync(
338 db, 463 db,
339 (IReadOnlyList<string>?)null, 464 (IReadOnlyList<string>?)null,
340 - regionIds,  
341 - locationIds); 465 + concreteRegions,
  466 + concreteLocations);
342 if (merged.Count == 0) 467 if (merged.Count == 0)
343 { 468 {
344 throw new UserFriendlyException("指定适用区域或门店时,至少需要匹配到一个有效门店"); 469 throw new UserFriendlyException("指定适用区域或门店时,至少需要匹配到一个有效门店");
@@ -349,6 +474,52 @@ public static class LocationScopeBindingHelper @@ -349,6 +474,52 @@ public static class LocationScopeBindingHelper
349 } 474 }
350 475
351 /// <summary> 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 /// 根据已绑定门店反推适用的 Company Id(<c>fl_partner.Id</c>)。 523 /// 根据已绑定门店反推适用的 Company Id(<c>fl_partner.Id</c>)。
353 /// </summary> 524 /// </summary>
354 public static async Task<List<string>> ResolvePartnerIdsFromLocationIdsAsync( 525 public static async Task<List<string>> ResolvePartnerIdsFromLocationIdsAsync(
@@ -591,7 +762,7 @@ public static class LocationScopeBindingHelper @@ -591,7 +762,7 @@ public static class LocationScopeBindingHelper
591 /// </summary> 762 /// </summary>
592 public static async Task ValidateLocationIdsExistAsync(ISqlSugarClient db, IReadOnlyList<string> locationIds) 763 public static async Task ValidateLocationIdsExistAsync(ISqlSugarClient db, IReadOnlyList<string> locationIds)
593 { 764 {
594 - var ids = NormalizeIds(locationIds); 765 + var ids = FilterConcreteScopeIds(locationIds);
595 if (ids.Count == 0) 766 if (ids.Count == 0)
596 { 767 {
597 return; 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,6 +67,18 @@ public static class ReportsPrintLogExpiryHelper
67 public static string ExtractFormattedExpiryText(string? printInputJson) => 67 public static string ExtractFormattedExpiryText(string? printInputJson) =>
68 ExtractFormattedExpiryText(printInputJson, null, null, null); 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 private static bool TryResolveExpiryDateTime( 82 private static bool TryResolveExpiryDateTime(
71 string? printInputJson, 83 string? printInputJson,
72 string? renderTemplateJson, 84 string? renderTemplateJson,
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs
  1 +using SqlSugar;
1 using Volo.Abp.Users; 2 using Volo.Abp.Users;
2 using Yi.Framework.Rbac.Domain.Shared.Consts; 3 using Yi.Framework.Rbac.Domain.Shared.Consts;
3 4
@@ -66,4 +67,28 @@ public static class ReportsRoleHelper @@ -66,4 +67,28 @@ public static class ReportsRoleHelper
66 67
67 return false; 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,7 +26,8 @@ public static class TeamMemberScopeDisplayHelper
26 Guid? roleId, 26 Guid? roleId,
27 IReadOnlyList<string> partnerIds, 27 IReadOnlyList<string> partnerIds,
28 IReadOnlyList<string> regionIds, 28 IReadOnlyList<string> regionIds,
29 - IReadOnlyList<TeamMemberAssignedLocationDto> assigned) 29 + IReadOnlyList<TeamMemberAssignedLocationDto> assigned,
  30 + string? appliedLocationType = null)
30 { 31 {
31 var assignedIds = assigned 32 var assignedIds = assigned
32 .Select(x => x.Id) 33 .Select(x => x.Id)
@@ -35,30 +36,43 @@ public static class TeamMemberScopeDisplayHelper @@ -35,30 +36,43 @@ public static class TeamMemberScopeDisplayHelper
35 .Distinct(StringComparer.Ordinal) 36 .Distinct(StringComparer.Ordinal)
36 .ToList(); 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 if (assignedIds.Count == 0) 57 if (assignedIds.Count == 0)
39 { 58 {
40 return assigned.ToList(); 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 return AllLocationDisplay; 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 return assigned.ToList(); 76 return assigned.ToList();
63 } 77 }
64 78
@@ -69,8 +83,15 @@ public static class TeamMemberScopeDisplayHelper @@ -69,8 +83,15 @@ public static class TeamMemberScopeDisplayHelper
69 ISqlSugarClient db, 83 ISqlSugarClient db,
70 IReadOnlyList<string> partnerIds, 84 IReadOnlyList<string> partnerIds,
71 IReadOnlyList<string> regionIds, 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 if (regionIds.Count == 0) 95 if (regionIds.Count == 0)
75 { 96 {
76 return FoodLabelingDisplayConsts.NotAvailable; 97 return FoodLabelingDisplayConsts.NotAvailable;
@@ -92,6 +113,118 @@ public static class TeamMemberScopeDisplayHelper @@ -92,6 +113,118 @@ public static class TeamMemberScopeDisplayHelper
92 : id)); 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 public static string FormatLocationTextForList(IReadOnlyList<TeamMemberAssignedLocationDto> assigned) 228 public static string FormatLocationTextForList(IReadOnlyList<TeamMemberAssignedLocationDto> assigned)
96 { 229 {
97 if (assigned.Count == 0) 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 using Volo.Abp; 1 using Volo.Abp;
2 using Volo.Abp.MultiTenancy; 2 using Volo.Abp.MultiTenancy;
  3 +using Yi.Framework.SqlSugarCore.Abstractions;
3 4
4 namespace FoodLabeling.Application.Helpers; 5 namespace FoodLabeling.Application.Helpers;
5 6
@@ -8,9 +9,15 @@ namespace FoodLabeling.Application.Helpers; @@ -8,9 +9,15 @@ namespace FoodLabeling.Application.Helpers;
8 /// </summary> 9 /// </summary>
9 public static class TenantContextGuard 10 public static class TenantContextGuard
10 { 11 {
  12 + /// <summary>
  13 + /// 平台主库登录(JWT/__tenant 均无业务租户)时访问 fl_* 等业务表的友好提示。
  14 + /// </summary>
  15 + public const string PlatformCannotAccessBusinessDataMessage =
  16 + "当前为平台主库登录,无法访问公司业务数据(标签/产品/成员等)。请选择具体公司登录,或使用平台「公司管理」相关接口。";
  17 +
11 public static void EnsureTenantResolved(ICurrentTenant currentTenant, string? operation = null) 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 return; 22 return;
16 } 23 }
@@ -19,6 +26,78 @@ public static class TenantContextGuard @@ -19,6 +26,78 @@ public static class TenantContextGuard
19 ? "未识别租户上下文" 26 ? "未识别租户上下文"
20 : $"{operation}:未识别租户上下文"; 27 : $"{operation}:未识别租户上下文";
21 throw new UserFriendlyException( 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,7 +27,10 @@ public static class UsAppAuthScopeHelper
27 } 27 }
28 28
29 var kind = currentUser.FindClaim(UsAppJwtClaims.ClientKind)?.Value; 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 throw new UserFriendlyException("请使用 App 登录令牌调用该接口"); 35 throw new UserFriendlyException("请使用 App 登录令牌调用该接口");
33 } 36 }
@@ -53,7 +56,13 @@ public static class UsAppAuthScopeHelper @@ -53,7 +56,13 @@ public static class UsAppAuthScopeHelper
53 return true; 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 public static async Task<List<AuthScopeCompanyOptionDto>> ListCompaniesAsync(ISqlSugarClient db) 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 using Microsoft.AspNetCore.Http; 2 using Microsoft.AspNetCore.Http;
2 using Volo.Abp.MultiTenancy; 3 using Volo.Abp.MultiTenancy;
3 -using Volo.Abp.Security.Claims;  
4 -using Yi.Framework.Rbac.Domain.Shared.Consts;  
5 4
6 namespace FoodLabeling.Application.MultiTenancy; 5 namespace FoodLabeling.Application.MultiTenancy;
7 6
8 /// <summary> 7 /// <summary>
9 -/// 从 JWT Claim <see cref="TokenTypeConst.TenantId"/> 解析当前租户(泰额版登录写入) 8 +/// 从 JWT Claim TenantId 解析当前租户(泰额版登录写入)
10 /// </summary> 9 /// </summary>
11 public class JwtClaimTenantResolveContributor : TenantResolveContributorBase 10 public class JwtClaimTenantResolveContributor : TenantResolveContributorBase
12 { 11 {
@@ -16,16 +15,17 @@ public class JwtClaimTenantResolveContributor : TenantResolveContributorBase @@ -16,16 +15,17 @@ public class JwtClaimTenantResolveContributor : TenantResolveContributorBase
16 15
17 public override Task ResolveAsync(ITenantResolveContext context) 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 return Task.CompletedTask; 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 context.TenantIdOrName = tenantClaim; 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,6 +3,7 @@ using FoodLabeling.Application.Contracts.Dtos.AuthSession;
3 using FoodLabeling.Application.Contracts.IServices; 3 using FoodLabeling.Application.Contracts.IServices;
4 using Microsoft.AspNetCore.Authorization; 4 using Microsoft.AspNetCore.Authorization;
5 using Microsoft.AspNetCore.Mvc; 5 using Microsoft.AspNetCore.Mvc;
  6 +using Microsoft.Extensions.Options;
6 using Volo.Abp; 7 using Volo.Abp;
7 using Volo.Abp.Application.Services; 8 using Volo.Abp.Application.Services;
8 using Volo.Abp.Caching; 9 using Volo.Abp.Caching;
@@ -24,24 +25,28 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService @@ -24,24 +25,28 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
24 private readonly IDistributedCache<SystemEditStampCacheItem, SystemEditStampCacheKey> _systemEditStampCache; 25 private readonly IDistributedCache<SystemEditStampCacheItem, SystemEditStampCacheKey> _systemEditStampCache;
25 private readonly ISqlSugarDbContext _dbContext; 26 private readonly ISqlSugarDbContext _dbContext;
26 private readonly ISqlSugarRepository<UserAggregateRoot, Guid> _userRepository; 27 private readonly ISqlSugarRepository<UserAggregateRoot, Guid> _userRepository;
  28 + private readonly DbConnOptions _dbConnOptions;
27 29
28 public AuthSessionAppService( 30 public AuthSessionAppService(
29 ISqlSugarDbContext dbContext, 31 ISqlSugarDbContext dbContext,
30 ISqlSugarRepository<UserAggregateRoot, Guid> userRepository, 32 ISqlSugarRepository<UserAggregateRoot, Guid> userRepository,
31 IDistributedCache<UserInfoCacheItem, UserInfoCacheKey> userCache, 33 IDistributedCache<UserInfoCacheItem, UserInfoCacheKey> userCache,
32 - IDistributedCache<SystemEditStampCacheItem, SystemEditStampCacheKey> systemEditStampCache) 34 + IDistributedCache<SystemEditStampCacheItem, SystemEditStampCacheKey> systemEditStampCache,
  35 + IOptions<DbConnOptions> dbConnOptions)
33 { 36 {
34 _dbContext = dbContext; 37 _dbContext = dbContext;
35 _userRepository = userRepository; 38 _userRepository = userRepository;
36 _userCache = userCache; 39 _userCache = userCache;
37 _systemEditStampCache = systemEditStampCache; 40 _systemEditStampCache = systemEditStampCache;
  41 + _dbConnOptions = dbConnOptions.Value;
38 } 42 }
39 43
40 /// <inheritdoc /> 44 /// <inheritdoc />
41 public virtual async Task<CurrentUserMenuPermissionsOutputDto> GetMyMenusAsync() 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 TenantContextGuard.EnsureTenantResolved(CurrentTenant, "获取菜单权限"); 51 TenantContextGuard.EnsureTenantResolved(CurrentTenant, "获取菜单权限");
47 } 52 }
@@ -66,35 +71,33 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService @@ -66,35 +71,33 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
66 .ToListAsync(); 71 .ToListAsync();
67 var distinctUserRoleIds = userRoleIds.Distinct().ToList(); 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 List<MenuDbEntity> menus; 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 menus = await _dbContext.SqlSugarClient.Queryable<MenuDbEntity>() 89 menus = await _dbContext.SqlSugarClient.Queryable<MenuDbEntity>()
74 .Where(x => x.IsDeleted == false) 90 .Where(x => x.IsDeleted == false)
75 .ToListAsync(); 91 .ToListAsync();
76 } 92 }
77 else 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 var menuNodes = menus 103 var menuNodes = menus
@@ -126,6 +129,7 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService @@ -126,6 +129,7 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
126 129
127 return new CurrentUserMenuPermissionsOutputDto 130 return new CurrentUserMenuPermissionsOutputDto
128 { 131 {
  132 + UserId = user.Id,
129 User = new CurrentUserBriefDto 133 User = new CurrentUserBriefDto
130 { 134 {
131 Id = user.Id, 135 Id = user.Id,
@@ -145,6 +149,30 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService @@ -145,6 +149,30 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
145 } 149 }
146 150
147 /// <summary> 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 /// 缓存未命中时,取主要业务表最近修改时间,避免 Last Updated 长期停在用户资料时间。 176 /// 缓存未命中时,取主要业务表最近修改时间,避免 Last Updated 长期停在用户资料时间。
149 /// </summary> 177 /// </summary>
150 private async Task<DateTime?> ResolveRecentBusinessEditTimeAsync() 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 using System.Text.Json; 2 using System.Text.Json;
3 using FoodLabeling.Application.Contracts.Dtos.Dashboard; 3 using FoodLabeling.Application.Contracts.Dtos.Dashboard;
4 using FoodLabeling.Application.Contracts.IServices; 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,6 +49,11 @@ public class FlLabelCategoryDbEntity
49 public string AvailabilityType { get; set; } = "ALL"; 49 public string AvailabilityType { get; set; } = "ALL";
50 50
51 /// <summary> 51 /// <summary>
  52 + /// 适用 Region 范围:ALL / SPECIFIED(ALL 时可同时 SPECIFIED 门店快照,表示全区下指定门店)
  53 + /// </summary>
  54 + public string AppliedRegionType { get; set; } = "ALL";
  55 +
  56 + /// <summary>
52 /// 适用 Company 范围:ALL / SPECIFIED 57 /// 适用 Company 范围:ALL / SPECIFIED
53 /// </summary> 58 /// </summary>
54 public string AppliedPartnerType { get; set; } = "ALL"; 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,6 +28,11 @@ public class FlLabelDbEntity
28 28
29 public string? LocationId { get; set; } 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 public string? LabelCategoryId { get; set; } 36 public string? LabelCategoryId { get; set; }
32 37
33 public string? LabelTypeId { get; set; } 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,6 +36,11 @@ public class FlLabelMultipleOptionDbEntity
36 public string AvailabilityType { get; set; } = "ALL"; 36 public string AvailabilityType { get; set; } = "ALL";
37 37
38 /// <summary> 38 /// <summary>
  39 + /// 适用 Region 范围:ALL / SPECIFIED
  40 + /// </summary>
  41 + public string AppliedRegionType { get; set; } = "ALL";
  42 +
  43 + /// <summary>
39 /// 适用 Company 范围:ALL / SPECIFIED 44 /// 适用 Company 范围:ALL / SPECIFIED
40 /// </summary> 45 /// </summary>
41 public string AppliedPartnerType { get; set; } = "ALL"; 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,6 +34,11 @@ public class FlLabelTypeDbEntity
34 public string AvailabilityType { get; set; } = "ALL"; 34 public string AvailabilityType { get; set; } = "ALL";
35 35
36 /// <summary> 36 /// <summary>
  37 + /// 适用 Region 范围:ALL / SPECIFIED
  38 + /// </summary>
  39 + public string AppliedRegionType { get; set; } = "ALL";
  40 +
  41 + /// <summary>
37 /// 适用 Company 范围:ALL / SPECIFIED 42 /// 适用 Company 范围:ALL / SPECIFIED
38 /// </summary> 43 /// </summary>
39 public string AppliedPartnerType { get; set; } = "ALL"; 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,6 +46,16 @@ public class FlProductCategoryDbEntity
46 /// </summary> 46 /// </summary>
47 public string AvailabilityType { get; set; } = "ALL"; 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 public int OrderNum { get; set; } 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,4 +36,9 @@ public class FlProductDbEntity
36 /// 适用门店:ALL / SPECIFIED(ALL 时动态包含后续新增门店) 36 /// 适用门店:ALL / SPECIFIED(ALL 时动态包含后续新增门店)
37 /// </summary> 37 /// </summary>
38 public string AvailabilityType { get; set; } = "SPECIFIED"; 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,6 +4,7 @@ using FoodLabeling.Application.Contracts.Dtos.Group;
4 using FoodLabeling.Application.Contracts.IServices; 4 using FoodLabeling.Application.Contracts.IServices;
5 using FoodLabeling.Application.Services.DbModels; 5 using FoodLabeling.Application.Services.DbModels;
6 using Microsoft.AspNetCore.Mvc; 6 using Microsoft.AspNetCore.Mvc;
  7 +using Microsoft.Extensions.Options;
7 using QuestPDF.Fluent; 8 using QuestPDF.Fluent;
8 using QuestPDF.Helpers; 9 using QuestPDF.Helpers;
9 using QuestPDF.Infrastructure; 10 using QuestPDF.Infrastructure;
@@ -25,16 +26,22 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -25,16 +26,22 @@ public class GroupAppService : ApplicationService, IGroupAppService
25 26
26 private readonly ISqlSugarDbContext _dbContext; 27 private readonly ISqlSugarDbContext _dbContext;
27 private readonly IGuidGenerator _guidGenerator; 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 _dbContext = dbContext; 36 _dbContext = dbContext;
32 _guidGenerator = guidGenerator; 37 _guidGenerator = guidGenerator;
  38 + _dbConnOptions = dbConnOptions.Value;
33 } 39 }
34 40
35 /// <inheritdoc /> 41 /// <inheritdoc />
36 public async Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input) 42 public async Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input)
37 { 43 {
  44 + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Region");
38 RefAsync<int> total = 0; 45 RefAsync<int> total = 0;
39 var query = await BuildGroupJoinedQueryAsync(input); 46 var query = await BuildGroupJoinedQueryAsync(input);
40 var projected = query.Select((g, p) => new GroupGetListOutputDto 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 +}