Commit 1aa1dca91e51af53c9afce2216a23c34024347c2

Authored by 杨鑫
2 parents ef6b3255 10fd1324

合并

Showing 64 changed files with 1990 additions and 351 deletions
.codex/project/project-context.md
@@ -66,3 +66,15 @@ @@ -66,3 +66,15 @@
66 - `打印机SDK/`、`nativeplugins/`、`scripts/`、`unpackage/` 等目录 66 - `打印机SDK/`、`nativeplugins/`、`scripts/`、`unpackage/` 等目录
67 67
68 后续修改时应避免误覆盖这些现有变更。 68 后续修改时应避免误覆盖这些现有变更。
  69 +
  70 +## 7. 美国版业务字段命名(固定映射)
  71 +
  72 +开发与文档中统一使用下列对应关系,**不要**在 `location` 表再新增名为 `Region` 的列:
  73 +
  74 +| 界面 / 接口英文 | 中文 | 主数据表 | 门店表 `location` 字段 |
  75 +|-----------------|------|----------|------------------------|
  76 +| Company / Partner | 公司 | `fl_partner` | `Partner`(公司名称字符串) |
  77 +| **Region / Group** | **区域 / 组织** | **`fl_group`**(`GroupName`) | **`GroupName`** |
  78 +| Location | 门店 | `location` | `LocationCode` / `LocationName` |
  79 +
  80 +**硬性规则**:**Region 对应 `location.GroupName`**(与 `fl_group.GroupName` 通过 `Partner` + 名称匹配)。列表/API 出参字段名可用 `region`,落库与查询一律用 `GroupName`。
美国版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.AspNetCore/Mvc/YiConventionalRouteBuilder.cs
@@ -78,6 +78,13 @@ namespace Yi.Framework.AspNetCore.Mvc @@ -78,6 +78,13 @@ namespace Yi.Framework.AspNetCore.Mvc
78 return baseUrl; 78 return baseUrl;
79 } 79 }
80 80
  81 + // Guid 必须带路由约束,否则与 export-xxx、download-xxx 等字面路径冲突
  82 + var idClrType = Nullable.GetUnderlyingType(idParameter.ParameterType) ?? idParameter.ParameterType;
  83 + if (idClrType == typeof(Guid))
  84 + {
  85 + return $"{baseUrl}/{{id:guid}}";
  86 + }
  87 +
81 // 处理原始类型ID 88 // 处理原始类型ID
82 if (TypeHelper.IsPrimitiveExtended(idParameter.ParameterType, includeEnums: true)) 89 if (TypeHelper.IsPrimitiveExtended(idParameter.ParameterType, includeEnums: true))
83 { 90 {
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/AuthSession/CurrentUserMenuPermissionsOutputDto.cs
@@ -12,4 +12,19 @@ public class CurrentUserMenuPermissionsOutputDto @@ -12,4 +12,19 @@ public class CurrentUserMenuPermissionsOutputDto
12 public List<string> PermissionCodes { get; set; } = new(); 12 public List<string> PermissionCodes { get; set; } = new();
13 13
14 public List<CurrentUserMenuNodeDto> Menus { get; set; } = new(); 14 public List<CurrentUserMenuNodeDto> Menus { get; set; } = new();
  15 +
  16 + /// <summary>
  17 + /// 用户资料最后更新时间(User.LastModificationTime,无则前端可忽略或展示「无」)
  18 + /// </summary>
  19 + public DateTime? LastUpdated { get; set; }
  20 +
  21 + /// <summary>
  22 + /// 角色展示名(多角色英文逗号拼接;与 <see cref="RoleCodes"/> 对应的库中 RoleName)
  23 + /// </summary>
  24 + public string Role { get; set; } = string.Empty;
  25 +
  26 + /// <summary>
  27 + /// 全名:优先姓名(User.Name),其次昵称(Nick),最后用户名
  28 + /// </summary>
  29 + public string FullName { get; set; } = string.Empty;
15 } 30 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelCreateInputVo.cs
@@ -8,7 +8,35 @@ public class LabelCreateInputVo @@ -8,7 +8,35 @@ public class LabelCreateInputVo
8 8
9 public string TemplateCode { get; set; } = string.Empty; 9 public string TemplateCode { get; set; } = string.Empty;
10 10
11 - public string LocationId { get; set; } = string.Empty; 11 + /// <summary>
  12 + /// 适用 Company(<c>fl_partner.Id</c>);与 <see cref="PartnerIds"/> 合并,用于解析所属门店
  13 + /// </summary>
  14 + public string? PartnerId { get; set; }
  15 +
  16 + /// <summary>
  17 + /// 适用 Company 多选(<c>fl_partner.Id</c>)
  18 + /// </summary>
  19 + public List<string>? PartnerIds { get; set; }
  20 +
  21 + /// <summary>
  22 + /// 适用 Region 多选(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并
  23 + /// </summary>
  24 + public List<string>? RegionIds { get; set; }
  25 +
  26 + /// <summary>
  27 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同)
  28 + /// </summary>
  29 + public List<string>? GroupIds { get; set; }
  30 +
  31 + /// <summary>
  32 + /// 所属门店(<c>location.Id</c>);可与 Company/Region 一并传入以校验范围;单独传则直接落库
  33 + /// </summary>
  34 + public string? LocationId { get; set; }
  35 +
  36 + /// <summary>
  37 + /// 门店候选(多选);标签仅绑定单门店:未传 <see cref="LocationId"/> 且合并结果唯一时自动取该项
  38 + /// </summary>
  39 + public List<string>? LocationIds { get; set; }
12 40
13 public string LabelCategoryId { get; set; } = string.Empty; 41 public string LabelCategoryId { get; set; } = string.Empty;
14 42
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetListInputVo.cs
@@ -10,7 +10,12 @@ public class LabelGetListInputVo : PagedAndSortedResultRequestDto @@ -10,7 +10,12 @@ public class LabelGetListInputVo : PagedAndSortedResultRequestDto
10 public string? Keyword { get; set; } 10 public string? Keyword { get; set; }
11 11
12 /// <summary> 12 /// <summary>
13 - /// 门店Id(可选) 13 + /// 组织/Region Id(<c>fl_group.Id</c>);筛选标签所属门店落在该 Region 下的记录
  14 + /// </summary>
  15 + public string? GroupId { get; set; }
  16 +
  17 + /// <summary>
  18 + /// 门店 Id(<c>location.Id</c>,Guid 字符串);最精确筛选,传则忽略 <see cref="GroupId"/>
14 /// </summary> 19 /// </summary>
15 public string? LocationId { get; set; } 20 public string? LocationId { get; set; }
16 21
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetOutputDto.cs
@@ -10,6 +10,17 @@ public class LabelGetOutputDto @@ -10,6 +10,17 @@ public class LabelGetOutputDto
10 10
11 public string LocationName { get; set; } = string.Empty; 11 public string LocationName { get; set; } = string.Empty;
12 12
  13 + /// <summary>适用 Company Id(由所属门店反推)</summary>
  14 + public string? PartnerId { get; set; }
  15 +
  16 + public List<string> PartnerIds { get; set; } = new();
  17 +
  18 + /// <summary>适用 Region Id(<c>fl_group.Id</c>)</summary>
  19 + public List<string> RegionIds { get; set; } = new();
  20 +
  21 + /// <summary>与 <see cref="RegionIds"/> 相同</summary>
  22 + public List<string> GroupIds { get; set; } = new();
  23 +
13 public string LabelCategoryId { get; set; } = string.Empty; 24 public string LabelCategoryId { get; set; } = string.Empty;
14 25
15 public string LabelCategoryName { get; set; } = string.Empty; 26 public string LabelCategoryName { get; set; } = string.Empty;
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelUpdateInputVo.cs
@@ -6,7 +6,17 @@ public class LabelUpdateInputVo @@ -6,7 +6,17 @@ public class LabelUpdateInputVo
6 6
7 public string TemplateCode { get; set; } = string.Empty; 7 public string TemplateCode { get; set; } = string.Empty;
8 8
9 - public string LocationId { get; set; } = string.Empty; 9 + public string? PartnerId { get; set; }
  10 +
  11 + public List<string>? PartnerIds { get; set; }
  12 +
  13 + public List<string>? RegionIds { get; set; }
  14 +
  15 + public List<string>? GroupIds { get; set; }
  16 +
  17 + public string? LocationId { get; set; }
  18 +
  19 + public List<string>? LocationIds { get; set; }
10 20
11 public string LabelCategoryId { get; set; } = string.Empty; 21 public string LabelCategoryId { get; set; } = string.Empty;
12 22
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryCreateInputVo.cs
@@ -29,7 +29,17 @@ public class LabelCategoryCreateInputVo @@ -29,7 +29,17 @@ public class LabelCategoryCreateInputVo
29 public string AvailabilityType { get; set; } = "ALL"; 29 public string AvailabilityType { get; set; } = "ALL";
30 30
31 /// <summary> 31 /// <summary>
32 - /// 指定门店 Id 列表(当 AvailabilityType=SPECIFIED 时必填) 32 + /// 适用 Region(多选),<c>fl_group.Id</c>;与 <see cref="GroupIds"/> 合并去重
  33 + /// </summary>
  34 + public List<string>? RegionIds { get; set; }
  35 +
  36 + /// <summary>
  37 + /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同
  38 + /// </summary>
  39 + public List<string>? GroupIds { get; set; }
  40 +
  41 + /// <summary>
  42 + /// 适用门店(多选),<c>location.Id</c>;与 Region 合并后写入 <c>fl_label_category_location</c>
33 /// </summary> 43 /// </summary>
34 public List<string>? LocationIds { get; set; } 44 public List<string>? LocationIds { get; set; }
35 45
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryGetListInputVo.cs
@@ -7,5 +7,15 @@ public class LabelCategoryGetListInputVo : PagedAndSortedResultRequestDto @@ -7,5 +7,15 @@ public class LabelCategoryGetListInputVo : PagedAndSortedResultRequestDto
7 public string? Keyword { get; set; } 7 public string? Keyword { get; set; }
8 8
9 public bool? State { get; set; } 9 public bool? State { get; set; }
  10 +
  11 + /// <summary>
  12 + /// Region 筛选(<c>fl_group.Id</c>);含 <c>availabilityType=ALL</c> 的分类
  13 + /// </summary>
  14 + public string? GroupId { get; set; }
  15 +
  16 + /// <summary>
  17 + /// 门店筛选(<c>location.Id</c>);优先于 <see cref="GroupId"/>
  18 + /// </summary>
  19 + public string? LocationId { get; set; }
10 } 20 }
11 21
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryGetListOutputDto.cs
@@ -18,15 +18,33 @@ public class LabelCategoryGetListOutputDto @@ -18,15 +18,33 @@ public class LabelCategoryGetListOutputDto
18 18
19 public string AvailabilityType { get; set; } = "ALL"; 19 public string AvailabilityType { get; set; } = "ALL";
20 20
21 - /// <summary>  
22 - /// 当 <see cref="AvailabilityType"/> 为 SPECIFIED 时返回绑定的门店 Id;否则为空列表。  
23 - /// </summary> 21 + /// <summary>适用 Region 展示(ALL 时为 All Regions)</summary>
  22 + public string Region { get; set; } = string.Empty;
  23 +
  24 + /// <summary>适用门店展示(ALL 时为 All Locations)</summary>
  25 + public string Location { get; set; } = string.Empty;
  26 +
  27 + /// <summary>适用 Region Id 列表(多选)</summary>
  28 + public List<string> RegionIds { get; set; } = new();
  29 +
  30 + /// <summary>适用门店 Id 列表(多选)</summary>
24 public List<string> LocationIds { get; set; } = new(); 31 public List<string> LocationIds { get; set; } = new();
25 32
26 public int OrderNum { get; set; } 33 public int OrderNum { get; set; }
27 34
  35 + /// <summary>
  36 + /// 该分类下已创建标签数量(<c>fl_label</c> 未删除且 <c>LabelCategoryId</c> 匹配)
  37 + /// </summary>
28 public long NoOfLabels { get; set; } 38 public long NoOfLabels { get; set; }
29 39
  40 + /// <summary>
  41 + /// 分类创建时间(兼容旧前端字段 <c>creationTime</c>)
  42 + /// </summary>
  43 + public DateTime CreationTime { get; set; }
  44 +
  45 + /// <summary>
  46 + /// 最后编辑时间:分类自身与下属标签的 <c>LastModificationTime</c>/<c>CreationTime</c> 取最大值
  47 + /// </summary>
30 public DateTime LastEdited { get; set; } 48 public DateTime LastEdited { get; set; }
31 } 49 }
32 50
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelCategory/LabelCategoryGetOutputDto.cs
@@ -18,6 +18,12 @@ public class LabelCategoryGetOutputDto @@ -18,6 +18,12 @@ public class LabelCategoryGetOutputDto
18 18
19 public string AvailabilityType { get; set; } = "ALL"; 19 public string AvailabilityType { get; set; } = "ALL";
20 20
  21 + /// <summary>适用 Region Id 列表(多选;<c>ALL</c> 时为空)</summary>
  22 + public List<string> RegionIds { get; set; } = new();
  23 +
  24 + /// <summary>与 <see cref="RegionIds"/> 相同</summary>
  25 + public List<string> GroupIds { get; set; } = new();
  26 +
21 public List<string> LocationIds { get; set; } = new(); 27 public List<string> LocationIds { get; set; } = new();
22 28
23 public int OrderNum { get; set; } 29 public int OrderNum { get; set; }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationCreateInputVo.cs
@@ -34,6 +34,11 @@ public class LocationCreateInputVo @@ -34,6 +34,11 @@ public class LocationCreateInputVo
34 34
35 public decimal? Longitude { get; set; } 35 public decimal? Longitude { get; set; }
36 36
  37 + /// <summary>
  38 + /// 经营时间(自由文本)
  39 + /// </summary>
  40 + public string? OperatingHours { get; set; }
  41 +
37 public bool State { get; set; } = true; 42 public bool State { get; set; } = true;
38 } 43 }
39 44
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationGetListOutputDto.cs
@@ -33,6 +33,11 @@ public class LocationGetListOutputDto @@ -33,6 +33,11 @@ public class LocationGetListOutputDto
33 33
34 public decimal? Longitude { get; set; } 34 public decimal? Longitude { get; set; }
35 35
  36 + /// <summary>
  37 + /// 经营时间
  38 + /// </summary>
  39 + public string? OperatingHours { get; set; }
  40 +
36 public bool State { get; set; } 41 public bool State { get; set; }
37 } 42 }
38 43
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationUpdateInputVo.cs
@@ -33,6 +33,11 @@ public class LocationUpdateInputVo @@ -33,6 +33,11 @@ public class LocationUpdateInputVo
33 public decimal? Longitude { get; set; } 33 public decimal? Longitude { get; set; }
34 34
35 /// <summary> 35 /// <summary>
  36 + /// 经营时间(自由文本)
  37 + /// </summary>
  38 + public string? OperatingHours { get; set; }
  39 +
  40 + /// <summary>
36 /// 启用状态 41 /// 启用状态
37 /// </summary> 42 /// </summary>
38 public bool State { get; set; } = true; 43 public bool State { get; set; } = true;
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerCreateInputVo.cs
@@ -3,7 +3,7 @@ namespace FoodLabeling.Application.Contracts.Dtos.Partner; @@ -3,7 +3,7 @@ namespace FoodLabeling.Application.Contracts.Dtos.Partner;
3 /// <summary> 3 /// <summary>
4 /// 新建合作伙伴入参 4 /// 新建合作伙伴入参
5 /// </summary> 5 /// </summary>
6 -public class PartnerCreateInputVo 6 +public class PartnerCreateInputVo : PartnerAddressFieldsDto
7 { 7 {
8 public string PartnerName { get; set; } = string.Empty; 8 public string PartnerName { get; set; } = string.Empty;
9 9
@@ -11,5 +11,8 @@ public class PartnerCreateInputVo @@ -11,5 +11,8 @@ public class PartnerCreateInputVo
11 11
12 public string? PhoneNumber { get; set; } 12 public string? PhoneNumber { get; set; }
13 13
  14 + /// <summary>
  15 + /// 是否启用(Active / Inactive)
  16 + /// </summary>
14 public bool State { get; set; } = true; 17 public bool State { get; set; } = true;
15 } 18 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetListOutputDto.cs
@@ -3,7 +3,7 @@ namespace FoodLabeling.Application.Contracts.Dtos.Partner; @@ -3,7 +3,7 @@ namespace FoodLabeling.Application.Contracts.Dtos.Partner;
3 /// <summary> 3 /// <summary>
4 /// 合作伙伴列表项 4 /// 合作伙伴列表项
5 /// </summary> 5 /// </summary>
6 -public class PartnerGetListOutputDto 6 +public class PartnerGetListOutputDto : PartnerAddressFieldsDto
7 { 7 {
8 public string Id { get; set; } = string.Empty; 8 public string Id { get; set; } = string.Empty;
9 9
@@ -13,6 +13,9 @@ public class PartnerGetListOutputDto @@ -13,6 +13,9 @@ public class PartnerGetListOutputDto
13 13
14 public string? PhoneNumber { get; set; } 14 public string? PhoneNumber { get; set; }
15 15
  16 + /// <summary>
  17 + /// 是否启用
  18 + /// </summary>
16 public bool State { get; set; } 19 public bool State { get; set; }
17 20
18 public DateTime CreationTime { get; set; } 21 public DateTime CreationTime { get; set; }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetOutputDto.cs
@@ -3,19 +3,7 @@ namespace FoodLabeling.Application.Contracts.Dtos.Partner; @@ -3,19 +3,7 @@ namespace FoodLabeling.Application.Contracts.Dtos.Partner;
3 /// <summary> 3 /// <summary>
4 /// 合作伙伴详情 4 /// 合作伙伴详情
5 /// </summary> 5 /// </summary>
6 -public class PartnerGetOutputDto 6 +public class PartnerGetOutputDto : PartnerGetListOutputDto
7 { 7 {
8 - public string Id { get; set; } = string.Empty;  
9 -  
10 - public string PartnerName { get; set; } = string.Empty;  
11 -  
12 - public string? ContactEmail { get; set; }  
13 -  
14 - public string? PhoneNumber { get; set; }  
15 -  
16 - public bool State { get; set; }  
17 -  
18 - public DateTime CreationTime { get; set; }  
19 -  
20 public DateTime? LastModificationTime { get; set; } 8 public DateTime? LastModificationTime { get; set; }
21 } 9 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerUpdateInputVo.cs
@@ -3,13 +3,6 @@ namespace FoodLabeling.Application.Contracts.Dtos.Partner; @@ -3,13 +3,6 @@ namespace FoodLabeling.Application.Contracts.Dtos.Partner;
3 /// <summary> 3 /// <summary>
4 /// 编辑合作伙伴入参 4 /// 编辑合作伙伴入参
5 /// </summary> 5 /// </summary>
6 -public class PartnerUpdateInputVo 6 +public class PartnerUpdateInputVo : PartnerCreateInputVo
7 { 7 {
8 - public string PartnerName { get; set; } = string.Empty;  
9 -  
10 - public string? ContactEmail { get; set; }  
11 -  
12 - public string? PhoneNumber { get; set; }  
13 -  
14 - public bool State { get; set; } = true;  
15 } 8 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs
@@ -18,8 +18,18 @@ public class ProductCreateInputVo @@ -18,8 +18,18 @@ public class ProductCreateInputVo
18 public bool State { get; set; } = true; 18 public bool State { get; set; } = true;
19 19
20 /// <summary> 20 /// <summary>
21 - /// 可选。门店 Id 列表;每个 Id 在 fl_location_product 落一行(同一 fl_product 可对应多门店)。  
22 - /// 不传或空列表则不在本接口写入门店关联(仍可用 product-location 接口维护)。 21 + /// 适用 Company(<c>fl_partner.Id</c>,UI 称 Company);展开该公司下全部门店后与 Region/门店合并写入 <c>fl_location_product</c>
  22 + /// </summary>
  23 + public string? PartnerId { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 适用 Region(<c>fl_group.Id</c>,UI 称 Region;库字段为 <c>location.GroupName</c>)
  27 + /// </summary>
  28 + public List<string>? GroupIds { get; set; }
  29 +
  30 + /// <summary>
  31 + /// 适用门店 Id 列表;与 <see cref="GroupIds"/> 合并后写入 <c>fl_location_product</c>。
  32 + /// 不传则不在本接口写入门店关联。
23 /// </summary> 33 /// </summary>
24 public List<string>? LocationIds { get; set; } 34 public List<string>? LocationIds { get; set; }
25 } 35 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetListInputVo.cs
@@ -17,5 +17,20 @@ public class ProductGetListInputVo : PagedAndSortedResultRequestDto @@ -17,5 +17,20 @@ public class ProductGetListInputVo : PagedAndSortedResultRequestDto
17 /// 启用状态 17 /// 启用状态
18 /// </summary> 18 /// </summary>
19 public bool? State { get; set; } 19 public bool? State { get; set; }
  20 +
  21 + /// <summary>
  22 + /// 公司(合作伙伴)Id,对应 <c>fl_partner.Id</c>;筛选绑定在该公司下门店的产品
  23 + /// </summary>
  24 + public string? PartnerId { get; set; }
  25 +
  26 + /// <summary>
  27 + /// 组织/Region Id,对应 <c>fl_group.Id</c>;筛选绑定在该 Region 对应门店下的产品(优先于 <see cref="PartnerId"/>)
  28 + /// </summary>
  29 + public string? GroupId { get; set; }
  30 +
  31 + /// <summary>
  32 + /// 门店 Id(<c>location.Id</c>,Guid 字符串);最精确筛选,传则忽略 <see cref="PartnerId"/> / <see cref="GroupId"/>
  33 + /// </summary>
  34 + public string? LocationId { get; set; }
20 } 35 }
21 36
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs
@@ -18,8 +18,17 @@ public class ProductGetOutputDto @@ -18,8 +18,17 @@ public class ProductGetOutputDto
18 18
19 public bool State { get; set; } 19 public bool State { get; set; }
20 20
  21 + /// <summary>适用 Company Id(<c>fl_partner.Id</c>,由关联门店反推;多公司时取第一个)</summary>
  22 + public string? PartnerId { get; set; }
  23 +
  24 + /// <summary>适用 Company Id 列表(去重)</summary>
  25 + public List<string> PartnerIds { get; set; } = new();
  26 +
  27 + /// <summary>适用 Region Id(<c>fl_group.Id</c>,由关联门店反推)</summary>
  28 + public List<string> GroupIds { get; set; } = new();
  29 +
21 /// <summary> 30 /// <summary>
22 - /// 该产品关联的门店Id列表(来自 fl_location_product) 31 + /// 适用门店 Id 列表(来自 fl_location_product)
23 /// </summary> 32 /// </summary>
24 public List<string> LocationIds { get; set; } = new(); 33 public List<string> LocationIds { get; set; } = new();
25 } 34 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs
@@ -32,7 +32,17 @@ public class ProductCategoryCreateInputVo @@ -32,7 +32,17 @@ public class ProductCategoryCreateInputVo
32 public string AvailabilityType { get; set; } = "ALL"; 32 public string AvailabilityType { get; set; } = "ALL";
33 33
34 /// <summary> 34 /// <summary>
35 - /// 指定门店 Id 列表(当 AvailabilityType=SPECIFIED 时必填) 35 + /// 适用 Region(**多选**),<c>fl_group.Id</c> 数组;与 <see cref="GroupIds"/> 等价,推荐本字段。
  36 + /// </summary>
  37 + public List<string>? RegionIds { get; set; }
  38 +
  39 + /// <summary>
  40 + /// 适用 Region(多选),与 <see cref="RegionIds"/> 相同;传任一会合并去重。
  41 + /// </summary>
  42 + public List<string>? GroupIds { get; set; }
  43 +
  44 + /// <summary>
  45 + /// 适用门店(**多选**),<c>location.Id</c> 数组;与 Region 合并后写入 <c>fl_product_category_location</c>。
36 /// </summary> 46 /// </summary>
37 public List<string>? LocationIds { get; set; } 47 public List<string>? LocationIds { get; set; }
38 48
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListInputVo.cs
@@ -16,5 +16,15 @@ public class ProductCategoryGetListInputVo : PagedAndSortedResultRequestDto @@ -16,5 +16,15 @@ public class ProductCategoryGetListInputVo : PagedAndSortedResultRequestDto
16 /// 启用状态过滤 16 /// 启用状态过滤
17 /// </summary> 17 /// </summary>
18 public bool? State { get; set; } 18 public bool? State { get; set; }
  19 +
  20 + /// <summary>
  21 + /// 按 Region 筛选(<c>fl_group.Id</c>):返回适用于该 Region 下任一门门店的分类,以及 <c>availabilityType=ALL</c> 的分类
  22 + /// </summary>
  23 + public string? GroupId { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 按门店筛选(<c>location.Id</c>,Guid 字符串);优先于 <see cref="GroupId"/>
  27 + /// </summary>
  28 + public string? LocationId { get; set; }
19 } 29 }
20 30
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs
@@ -24,5 +24,21 @@ public class ProductCategoryGetListOutputDto @@ -24,5 +24,21 @@ public class ProductCategoryGetListOutputDto
24 public int OrderNum { get; set; } 24 public int OrderNum { get; set; }
25 25
26 public DateTime LastEdited { get; set; } 26 public DateTime LastEdited { get; set; }
  27 +
  28 + /// <summary>
  29 + /// 适用 Region 展示(<c>availabilityType=ALL</c> 时为 <c>All Regions</c>;<c>SPECIFIED</c> 时为绑定门店去重后的 <c>location.GroupName</c>,英文逗号拼接)
  30 + /// </summary>
  31 + public string Region { get; set; } = string.Empty;
  32 +
  33 + /// <summary>
  34 + /// 适用门店展示(<c>availabilityType=ALL</c> 时为 <c>All Locations</c>;<c>SPECIFIED</c> 时为绑定门店名称,英文逗号拼接)
  35 + /// </summary>
  36 + public string Location { get; set; } = string.Empty;
  37 +
  38 + /// <summary>适用 Region Id 列表(多选;<c>ALL</c> 时为空数组)</summary>
  39 + public List<string> RegionIds { get; set; } = new();
  40 +
  41 + /// <summary>适用门店 Id 列表(多选;<c>ALL</c> 时为空数组)</summary>
  42 + public List<string> LocationIds { get; set; } = new();
27 } 43 }
28 44
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs
@@ -22,6 +22,13 @@ public class ProductCategoryGetOutputDto @@ -22,6 +22,13 @@ public class ProductCategoryGetOutputDto
22 22
23 public string AvailabilityType { get; set; } = "ALL"; 23 public string AvailabilityType { get; set; } = "ALL";
24 24
  25 + /// <summary>适用 Region Id 列表(多选,<c>fl_group.Id</c>;由绑定门店反推)</summary>
  26 + public List<string> RegionIds { get; set; } = new();
  27 +
  28 + /// <summary>与 <see cref="RegionIds"/> 相同(兼容字段)</summary>
  29 + public List<string> GroupIds { get; set; } = new();
  30 +
  31 + /// <summary>适用门店 Id 列表(多选,<c>location.Id</c>)</summary>
25 public List<string> LocationIds { get; set; } = new(); 32 public List<string> LocationIds { get; set; } = new();
26 33
27 public int OrderNum { get; set; } 34 public int OrderNum { get; set; }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleCreateInputVo.cs
@@ -19,18 +19,18 @@ public class RbacRoleCreateInputVo @@ -19,18 +19,18 @@ public class RbacRoleCreateInputVo
19 public bool State { get; set; } = true; 19 public bool State { get; set; } = true;
20 20
21 /// <summary> 21 /// <summary>
22 - /// 排序;未传时服务端按 0 处理 22 + /// 排序号;不传或传 null 时新增默认为 0,编辑时保留原值
23 /// </summary> 23 /// </summary>
24 public int? OrderNum { get; set; } 24 public int? OrderNum { get; set; }
25 25
26 /// <summary> 26 /// <summary>
27 - /// 访问权限编码 JSON 数组字符串(如 ["manage_labels","manage_people"]) 27 + /// 绑定菜单 Id;与 accessPermissions 同时传时以本字段为准
28 /// </summary> 28 /// </summary>
29 - public string? AccessPermissions { get; set; } 29 + public List<Guid>? MenuIds { get; set; }
30 30
31 /// <summary> 31 /// <summary>
32 - /// 兼容旧字段名 <c>accessPermissionCodes</c> 32 + /// 按 PermissionCode 绑定菜单(英文逗号分隔);传空字符串表示清空绑定;不传则不修改已有绑定(仅编辑时)
33 /// </summary> 33 /// </summary>
34 - public List<string>? AccessPermissionCodes { get; set; } 34 + public string? AccessPermissions { get; set; }
35 } 35 }
36 36
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleGetListOutputDto.cs
@@ -20,8 +20,8 @@ public class RbacRoleGetListOutputDto @@ -20,8 +20,8 @@ public class RbacRoleGetListOutputDto
20 public int OrderNum { get; set; } 20 public int OrderNum { get; set; }
21 21
22 /// <summary> 22 /// <summary>
23 - /// 访问权限编码列表 23 + /// 已绑定菜单的 PermissionCode 汇总(英文逗号+空格拼接,与 /api/app/role 一致)
24 /// </summary> 24 /// </summary>
25 - public List<string> AccessPermissionCodes { get; set; } = new(); 25 + public string AccessPermissions { get; set; } = string.Empty;
26 } 26 }
27 27
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs
@@ -18,9 +18,29 @@ public class TeamMemberCreateInputVo @@ -18,9 +18,29 @@ public class TeamMemberCreateInputVo
18 public Guid? RoleId { get; set; } 18 public Guid? RoleId { get; set; }
19 19
20 /// <summary> 20 /// <summary>
21 - /// 关联门店(至少1个) 21 + /// 适用 Company(<c>fl_partner.Id</c>,UI 称 Company);与 <see cref="PartnerIds"/> 合并
22 /// </summary> 22 /// </summary>
23 - public List<string> LocationIds { get; set; } = new(); 23 + public string? PartnerId { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 适用 Company 多选(<c>fl_partner.Id</c>)
  27 + /// </summary>
  28 + public List<string>? PartnerIds { get; set; }
  29 +
  30 + /// <summary>
  31 + /// 适用 Region 多选(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并
  32 + /// </summary>
  33 + public List<string>? RegionIds { get; set; }
  34 +
  35 + /// <summary>
  36 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同)
  37 + /// </summary>
  38 + public List<string>? GroupIds { get; set; }
  39 +
  40 + /// <summary>
  41 + /// 适用门店多选(<c>location.Id</c>);与 Company/Region 合并后写入 <c>userlocation</c>
  42 + /// </summary>
  43 + public List<string>? LocationIds { get; set; }
24 44
25 public bool State { get; set; } = true; 45 public bool State { get; set; } = true;
26 } 46 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetListOutputDto.cs
@@ -21,6 +21,12 @@ public class TeamMemberGetListOutputDto @@ -21,6 +21,12 @@ public class TeamMemberGetListOutputDto
21 21
22 public string? RoleName { get; set; } 22 public string? RoleName { get; set; }
23 23
  24 + /// <summary>适用 Company Id 列表(多选)</summary>
  25 + public List<string> PartnerIds { get; set; } = new();
  26 +
  27 + /// <summary>适用 Region Id 列表(多选)</summary>
  28 + public List<string> RegionIds { get; set; } = new();
  29 +
24 public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new(); 30 public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new();
25 } 31 }
26 32
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetOutputDto.cs
@@ -16,6 +16,15 @@ public class TeamMemberGetOutputDto @@ -16,6 +16,15 @@ public class TeamMemberGetOutputDto
16 16
17 public Guid? RoleId { get; set; } 17 public Guid? RoleId { get; set; }
18 18
  19 + /// <summary>适用 Company Id(多选,由绑定门店反推)</summary>
  20 + public List<string> PartnerIds { get; set; } = new();
  21 +
  22 + /// <summary>适用 Region Id(多选,<c>fl_group.Id</c>)</summary>
  23 + public List<string> RegionIds { get; set; } = new();
  24 +
  25 + /// <summary>与 <see cref="RegionIds"/> 相同</summary>
  26 + public List<string> GroupIds { get; set; } = new();
  27 +
19 public List<string> LocationIds { get; set; } = new(); 28 public List<string> LocationIds { get; set; } = new();
20 29
21 public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new(); 30 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
@@ -17,7 +17,30 @@ public class TeamMemberUpdateInputVo @@ -17,7 +17,30 @@ public class TeamMemberUpdateInputVo
17 17
18 public Guid? RoleId { get; set; } 18 public Guid? RoleId { get; set; }
19 19
20 - public List<string> LocationIds { get; set; } = new(); 20 + /// <summary>
  21 + /// 适用 Company(<c>fl_partner.Id</c>);与 <see cref="PartnerIds"/> 合并
  22 + /// </summary>
  23 + public string? PartnerId { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 适用 Company 多选(<c>fl_partner.Id</c>)
  27 + /// </summary>
  28 + public List<string>? PartnerIds { get; set; }
  29 +
  30 + /// <summary>
  31 + /// 适用 Region 多选(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并
  32 + /// </summary>
  33 + public List<string>? RegionIds { get; set; }
  34 +
  35 + /// <summary>
  36 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同)
  37 + /// </summary>
  38 + public List<string>? GroupIds { get; set; }
  39 +
  40 + /// <summary>
  41 + /// 适用门店多选(<c>location.Id</c>);与 Company/Region 合并后写入 <c>userlocation</c>
  42 + /// </summary>
  43 + public List<string>? LocationIds { get; set; }
21 44
22 public bool State { get; set; } = true; 45 public bool State { get; set; } = true;
23 } 46 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/FoodLabeling.Application.Contracts.csproj
@@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
3 3
4 <ItemGroup> 4 <ItemGroup>
5 <ProjectReference Include="..\..\..\framework\Yi.Framework.Ddd.Application.Contracts\Yi.Framework.Ddd.Application.Contracts.csproj" /> 5 <ProjectReference Include="..\..\..\framework\Yi.Framework.Ddd.Application.Contracts\Yi.Framework.Ddd.Application.Contracts.csproj" />
  6 + <ProjectReference Include="..\..\rbac\Yi.Framework.Rbac.Application.Contracts\Yi.Framework.Rbac.Application.Contracts.csproj" />
6 <ProjectReference Include="..\FoodLabeling.Domain.Shared\FoodLabeling.Domain.Shared.csproj" /> 7 <ProjectReference Include="..\FoodLabeling.Domain.Shared\FoodLabeling.Domain.Shared.csproj" />
7 <ProjectReference Include="..\..\rbac\Yi.Framework.Rbac.Domain.Shared\Yi.Framework.Rbac.Domain.Shared.csproj" /> 8 <ProjectReference Include="..\..\rbac\Yi.Framework.Rbac.Domain.Shared\Yi.Framework.Rbac.Domain.Shared.csproj" />
8 </ItemGroup> 9 </ItemGroup>
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IAuthSessionAppService.cs
@@ -13,6 +13,8 @@ public interface IAuthSessionAppService : IApplicationService @@ -13,6 +13,8 @@ public interface IAuthSessionAppService : IApplicationService
13 /// </summary> 13 /// </summary>
14 /// <remarks> 14 /// <remarks>
15 /// 与框架 <c>UserManager.GetInfoAsync</c> 一致;用户名为 <c>admin</c> 时返回全部未删除菜单(与 <c>AccountService.GetVue3Router</c> 行为对齐)。 15 /// 与框架 <c>UserManager.GetInfoAsync</c> 一致;用户名为 <c>admin</c> 时返回全部未删除菜单(与 <c>AccountService.GetVue3Router</c> 行为对齐)。
  16 + /// 返回体额外包含:<c>lastUpdated</c>(用户 LastModificationTime)、<c>role</c>(角色展示名,多角色英文逗号拼接)、<c>fullName</c>(姓名优先,其次昵称、用户名)。
  17 + /// 角色名通过 <c>Role</c> 表直查(<c>RoleDbEntity</c>),避免走仓储 IDataPermission。
16 /// </remarks> 18 /// </remarks>
17 /// <returns>用户简要信息、权限码与菜单树</returns> 19 /// <returns>用户简要信息、权限码与菜单树</returns>
18 /// <response code="200">成功</response> 20 /// <response code="200">成功</response>
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IGroupAppService.cs
@@ -14,13 +14,18 @@ public interface IGroupAppService : IApplicationService @@ -14,13 +14,18 @@ public interface IGroupAppService : IApplicationService
14 /// <summary> 14 /// <summary>
15 /// 组织分页列表(与导出使用相同筛选条件) 15 /// 组织分页列表(与导出使用相同筛选条件)
16 /// </summary> 16 /// </summary>
  17 + /// <remarks>
  18 + /// 数据范围(按登录 Token):
  19 + /// 管理员可见全部 Region;其它角色仅可见其 <c>userlocation</c> 绑定门店对应的组织(<c>location.Partner</c> + <c>location.GroupName</c> 与 <c>fl_group</c> 匹配)。
  20 + /// </remarks>
17 /// <param name="input">分页与筛选;SkipCount 为页码(从 1 起)</param> 21 /// <param name="input">分页与筛选;SkipCount 为页码(从 1 起)</param>
18 Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input); 22 Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input);
19 23
20 /// <summary> 24 /// <summary>
21 /// 组织详情 25 /// 组织详情
22 /// </summary> 26 /// </summary>
23 - Task<GroupGetOutputDto> GetAsync(string id); 27 + /// <param name="id">主键(Guid,与 <c>fl_group.Id</c> 一致;约定路由 <c>{id:guid}</c>,避免与 <c>export-pdf</c> 等路径冲突)</param>
  28 + Task<GroupGetOutputDto> GetAsync(Guid id);
24 29
25 /// <summary> 30 /// <summary>
26 /// 新增组织 31 /// 新增组织
@@ -30,16 +35,19 @@ public interface IGroupAppService : IApplicationService @@ -30,16 +35,19 @@ public interface IGroupAppService : IApplicationService
30 /// <summary> 35 /// <summary>
31 /// 编辑组织 36 /// 编辑组织
32 /// </summary> 37 /// </summary>
33 - Task<GroupGetOutputDto> UpdateAsync(string id, GroupUpdateInputVo input); 38 + Task<GroupGetOutputDto> UpdateAsync(Guid id, GroupUpdateInputVo input);
34 39
35 /// <summary> 40 /// <summary>
36 /// 删除组织(逻辑删除) 41 /// 删除组织(逻辑删除)
37 /// </summary> 42 /// </summary>
38 - Task DeleteAsync(string id); 43 + Task DeleteAsync(Guid id);
39 44
40 /// <summary> 45 /// <summary>
41 - /// 按列表相同筛选条件全量导出组织(Region)为 PDF(不分页;与 <see cref="GetListAsync"/> 相同筛选;单次最多 5000 条) 46 + /// 按列表相同筛选条件全量导出组织(Region)为 PDF(不分页;与 <see cref="GetListAsync"/> 相同筛选与 Token 数据范围;单次最多 5000 条)
42 /// </summary> 47 /// </summary>
  48 + /// <remarks>
  49 + /// 与 <see cref="GetListAsync"/> 使用同一套 <c>BuildGroupJoinedQueryAsync</c>(含 <c>ResolveGroupScopeAsync</c>),导出行集与列表一致。
  50 + /// </remarks>
43 /// <param name="input">Keyword、PartnerId、State、Sorting;分页字段忽略</param> 51 /// <param name="input">Keyword、PartnerId、State、Sorting;分页字段忽略</param>
44 /// <returns>application/pdf</returns> 52 /// <returns>application/pdf</returns>
45 Task<IActionResult> ExportPdfAsync(GroupGetListInputVo input); 53 Task<IActionResult> ExportPdfAsync(GroupGetListInputVo input);
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IPartnerAppService.cs
@@ -14,6 +14,13 @@ public interface IPartnerAppService : IApplicationService @@ -14,6 +14,13 @@ public interface IPartnerAppService : IApplicationService
14 /// <summary> 14 /// <summary>
15 /// 合作伙伴分页列表(与导出使用相同筛选条件) 15 /// 合作伙伴分页列表(与导出使用相同筛选条件)
16 /// </summary> 16 /// </summary>
  17 + /// <remarks>
  18 + /// 数据范围(按登录 Token):
  19 + /// <list type="bullet">
  20 + /// <item>管理员(角色码 <c>admin</c>、用户名为 <c>admin</c> 或权限 <c>*:*:*</c>):可查看全部公司;</item>
  21 + /// <item>其它角色:仅可查看当前用户在 <c>userlocation</c> 中绑定门店所属的合作伙伴(<c>location.Partner</c> 与 <c>fl_partner</c> 按名称或 Id 匹配)。</item>
  22 + /// </list>
  23 + /// </remarks>
17 /// <param name="input">分页与筛选;SkipCount 为页码(从 1 起)</param> 24 /// <param name="input">分页与筛选;SkipCount 为页码(从 1 起)</param>
18 /// <returns>分页数据</returns> 25 /// <returns>分页数据</returns>
19 /// <response code="200">成功</response> 26 /// <response code="200">成功</response>
@@ -24,17 +31,17 @@ public interface IPartnerAppService : IApplicationService @@ -24,17 +31,17 @@ public interface IPartnerAppService : IApplicationService
24 /// <summary> 31 /// <summary>
25 /// 合作伙伴详情 32 /// 合作伙伴详情
26 /// </summary> 33 /// </summary>
27 - /// <param name="id">主键 Id</param> 34 + /// <param name="id">主键(Guid,与 <c>fl_partner.Id</c> 一致;约定路由 <c>{id:guid}</c>,避免与 <c>export-pdf</c> 等路径冲突)</param>
28 /// <returns>详情</returns> 35 /// <returns>详情</returns>
29 /// <response code="200">成功</response> 36 /// <response code="200">成功</response>
30 /// <response code="400">Id 无效</response> 37 /// <response code="400">Id 无效</response>
31 /// <response code="500">服务器错误</response> 38 /// <response code="500">服务器错误</response>
32 - Task<PartnerGetOutputDto> GetAsync(string id); 39 + Task<PartnerGetOutputDto> GetAsync(Guid id);
33 40
34 /// <summary> 41 /// <summary>
35 /// 新增合作伙伴 42 /// 新增合作伙伴
36 /// </summary> 43 /// </summary>
37 - /// <param name="input">名称、邮箱、电话、启用状态</param> 44 + /// <param name="input">名称、联系信息、地址、启用状态</param>
38 /// <returns>新建后的详情</returns> 45 /// <returns>新建后的详情</returns>
39 /// <remarks> 46 /// <remarks>
40 /// 示例请求: 47 /// 示例请求:
@@ -43,9 +50,15 @@ public interface IPartnerAppService : IApplicationService @@ -43,9 +50,15 @@ public interface IPartnerAppService : IApplicationService
43 /// "partnerName": "Global Foods Inc.", 50 /// "partnerName": "Global Foods Inc.",
44 /// "contactEmail": "admin@globalfoods.com", 51 /// "contactEmail": "admin@globalfoods.com",
45 /// "phoneNumber": "+1 (555) 100-2000", 52 /// "phoneNumber": "+1 (555) 100-2000",
  53 + /// "street": "123 Main St",
  54 + /// "city": "New York",
  55 + /// "stateCode": "NY",
  56 + /// "country": "USA",
  57 + /// "zipCode": "10001",
46 /// "state": true 58 /// "state": true
47 /// } 59 /// }
48 /// ``` 60 /// ```
  61 + /// 地址中的州/省请传 <c>stateCode</c>(如 NY);<c>state</c>(boolean)表示是否启用。
49 /// </remarks> 62 /// </remarks>
50 /// <response code="200">成功</response> 63 /// <response code="200">成功</response>
51 /// <response code="400">校验失败</response> 64 /// <response code="400">校验失败</response>
@@ -56,12 +69,12 @@ public interface IPartnerAppService : IApplicationService @@ -56,12 +69,12 @@ public interface IPartnerAppService : IApplicationService
56 /// 编辑合作伙伴 69 /// 编辑合作伙伴
57 /// </summary> 70 /// </summary>
58 /// <param name="id">主键 Id</param> 71 /// <param name="id">主键 Id</param>
59 - /// <param name="input">名称、邮箱、电话、启用状态</param> 72 + /// <param name="input">名称、联系信息、地址、启用状态(字段同新增)</param>
60 /// <returns>更新后的详情</returns> 73 /// <returns>更新后的详情</returns>
61 /// <response code="200">成功</response> 74 /// <response code="200">成功</response>
62 /// <response code="400">校验失败或记录不存在</response> 75 /// <response code="400">校验失败或记录不存在</response>
63 /// <response code="500">服务器错误</response> 76 /// <response code="500">服务器错误</response>
64 - Task<PartnerGetOutputDto> UpdateAsync(string id, PartnerUpdateInputVo input); 77 + Task<PartnerGetOutputDto> UpdateAsync(Guid id, PartnerUpdateInputVo input);
65 78
66 /// <summary> 79 /// <summary>
67 /// 删除合作伙伴(逻辑删除) 80 /// 删除合作伙伴(逻辑删除)
@@ -70,7 +83,7 @@ public interface IPartnerAppService : IApplicationService @@ -70,7 +83,7 @@ public interface IPartnerAppService : IApplicationService
70 /// <response code="200">成功</response> 83 /// <response code="200">成功</response>
71 /// <response code="400">Id 无效或记录不存在</response> 84 /// <response code="400">Id 无效或记录不存在</response>
72 /// <response code="500">服务器错误</response> 85 /// <response code="500">服务器错误</response>
73 - Task DeleteAsync(string id); 86 + Task DeleteAsync(Guid id);
74 87
75 /// <summary> 88 /// <summary>
76 /// 按当前列表筛选条件批量导出合作伙伴为 PDF(Account Management「Company」页签;不分页,上限 5000 条) 89 /// 按当前列表筛选条件批量导出合作伙伴为 PDF(Account Management「Company」页签;不分页,上限 5000 条)
@@ -78,7 +91,7 @@ public interface IPartnerAppService : IApplicationService @@ -78,7 +91,7 @@ public interface IPartnerAppService : IApplicationService
78 /// <param name="input">与列表相同的 Keyword、State;分页字段忽略</param> 91 /// <param name="input">与列表相同的 Keyword、State;分页字段忽略</param>
79 /// <returns>PDF 文件流</returns> 92 /// <returns>PDF 文件流</returns>
80 /// <remarks> 93 /// <remarks>
81 - /// 筛选条件需与 <see cref="GetListAsync"/> 一致,便于统计与导出数据对齐 94 + /// 筛选条件与数据范围需与 <see cref="GetListAsync"/> 完全一致(含 Token 权限:管理员全部公司,其它角色仅绑定门店所属公司;见 PartnerScopeHelper)
82 /// </remarks> 95 /// </remarks>
83 /// <response code="200">成功返回 application/pdf</response> 96 /// <response code="200">成功返回 application/pdf</response>
84 /// <response code="400">参数错误</response> 97 /// <response code="400">参数错误</response>
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs
@@ -14,19 +14,26 @@ public interface IProductAppService : IApplicationService @@ -14,19 +14,26 @@ public interface IProductAppService : IApplicationService
14 /// <summary> 14 /// <summary>
15 /// 产品分页列表 15 /// 产品分页列表
16 /// </summary> 16 /// </summary>
  17 + /// <remarks>
  18 + /// 支持按公司 <c>partnerId</c>、组织 <c>groupId</c>、门店 <c>locationId</c> 筛选:
  19 + /// 仅返回在 <c>fl_location_product</c> 中关联到匹配门店的产品;三者均未传时不按门店收窄。
  20 + /// <c>locationId</c> 最优先;传 <c>groupId</c> 时按 Region 对应门店的 Partner+GroupName 匹配。
  21 + /// </remarks>
17 Task<PagedResultWithPageDto<ProductGetListOutputDto>> GetListAsync(ProductGetListInputVo input); 22 Task<PagedResultWithPageDto<ProductGetListOutputDto>> GetListAsync(ProductGetListInputVo input);
18 23
19 /// <summary> 24 /// <summary>
20 /// 产品详情 25 /// 产品详情
21 /// </summary> 26 /// </summary>
22 - Task<ProductGetOutputDto> GetAsync(string id); 27 + /// <param name="id">产品主键(Guid,与 <c>fl_product.Id</c> 一致;使用 Guid 路由约束,避免与 <c>export-*</c> 等字面路径冲突)</param>
  28 + Task<ProductGetOutputDto> GetAsync(Guid id);
23 29
24 /// <summary> 30 /// <summary>
25 /// 新增产品 31 /// 新增产品
26 /// </summary> 32 /// </summary>
27 /// <remarks> 33 /// <remarks>
28 /// <see cref="ProductCreateInputVo.ProductCode"/> 可选;为空时后端生成唯一编码(如 PRD_ + Guid)。 34 /// <see cref="ProductCreateInputVo.ProductCode"/> 可选;为空时后端生成唯一编码(如 PRD_ + Guid)。
29 - /// 若 <see cref="ProductCreateInputVo.LocationIds"/> 有值,将在同一事务内批量写入 fl_location_product(一门店一条)。 35 + /// 若传 <see cref="ProductCreateInputVo.PartnerId"/>(Company)、<see cref="ProductCreateInputVo.GroupIds"/>(Region)
  36 + /// 和/或 <see cref="ProductCreateInputVo.LocationIds"/>,合并后写入 fl_location_product。
30 /// </remarks> 37 /// </remarks>
31 Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input); 38 Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input);
32 39
@@ -34,15 +41,15 @@ public interface IProductAppService : IApplicationService @@ -34,15 +41,15 @@ public interface IProductAppService : IApplicationService
34 /// 编辑产品 41 /// 编辑产品
35 /// </summary> 42 /// </summary>
36 /// <remarks> 43 /// <remarks>
37 - /// 当请求体包含 <see cref="ProductCreateInputVo.LocationIds"/> 属性时,按该列表整表替换本产品在各门店的关联;  
38 - /// 不传该属性则不改门店关联(兼容仅改名称/分类等调用)。 44 + /// 当请求体包含 <see cref="ProductCreateInputVo.PartnerId"/>、<see cref="ProductCreateInputVo.GroupIds"/>
  45 + /// 和/或 <see cref="ProductCreateInputVo.LocationIds"/> 时,合并后整表替换门店关联;均不传则不改。
39 /// </remarks> 46 /// </remarks>
40 - Task<ProductGetOutputDto> UpdateAsync(string id, ProductUpdateInputVo input); 47 + Task<ProductGetOutputDto> UpdateAsync(Guid id, ProductUpdateInputVo input);
41 48
42 /// <summary> 49 /// <summary>
43 /// 删除产品(逻辑删除) 50 /// 删除产品(逻辑删除)
44 /// </summary> 51 /// </summary>
45 - Task DeleteAsync(string id); 52 + Task DeleteAsync(Guid id);
46 53
47 /// <summary> 54 /// <summary>
48 /// 下载 Product 批量导入模板(服务器 <c>TemplateDirectory</c> 下 xlsx) 55 /// 下载 Product 批量导入模板(服务器 <c>TemplateDirectory</c> 下 xlsx)
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs
@@ -9,12 +9,21 @@ namespace FoodLabeling.Application.Contracts.IServices; @@ -9,12 +9,21 @@ namespace FoodLabeling.Application.Contracts.IServices;
9 /// </summary> 9 /// </summary>
10 public interface IProductCategoryAppService : IApplicationService 10 public interface IProductCategoryAppService : IApplicationService
11 { 11 {
  12 + /// <summary>
  13 + /// 类别分页列表;支持 <c>groupId</c>/<c>locationId</c> 筛选;出参含 <c>region</c>、<c>location</c> 展示字段。
  14 + /// </summary>
12 Task<PagedResultWithPageDto<ProductCategoryGetListOutputDto>> GetListAsync(ProductCategoryGetListInputVo input); 15 Task<PagedResultWithPageDto<ProductCategoryGetListOutputDto>> GetListAsync(ProductCategoryGetListInputVo input);
13 16
14 Task<ProductCategoryGetOutputDto> GetAsync(string id); 17 Task<ProductCategoryGetOutputDto> GetAsync(string id);
15 18
  19 + /// <summary>
  20 + /// 新增类别;body 传 <c>regionIds</c>(Region 多选)与 <c>locationIds</c>(门店多选)绑定适用范围。
  21 + /// </summary>
16 Task<ProductCategoryGetOutputDto> CreateAsync(ProductCategoryCreateInputVo input); 22 Task<ProductCategoryGetOutputDto> CreateAsync(ProductCategoryCreateInputVo input);
17 23
  24 + /// <summary>
  25 + /// 编辑类别;<c>regionIds</c>/<c>locationIds</c> 多选数组规则同新增;传空数组 <c>[]</c> 可清空对应范围。
  26 + /// </summary>
18 Task<ProductCategoryGetOutputDto> UpdateAsync(string id, ProductCategoryUpdateInputVo input); 27 Task<ProductCategoryGetOutputDto> UpdateAsync(string id, ProductCategoryUpdateInputVo input);
19 28
20 Task DeleteAsync(string id); 29 Task DeleteAsync(string id);
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs
1 using FoodLabeling.Application.Contracts.Dtos.UsAppAuth; 1 using FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
2 using Volo.Abp.Application.Services; 2 using Volo.Abp.Application.Services;
  3 +using Yi.Framework.Rbac.Application.Contracts.Dtos.Account;
3 4
4 namespace FoodLabeling.Application.Contracts.IServices; 5 namespace FoodLabeling.Application.Contracts.IServices;
5 6
@@ -48,4 +49,26 @@ public interface IUsAppAuthAppService : IApplicationService @@ -48,4 +49,26 @@ public interface IUsAppAuthAppService : IApplicationService
48 /// <response code="400">参数非法、未绑定或无权限</response> 49 /// <response code="400">参数非法、未绑定或无权限</response>
49 /// <response code="500">服务器错误</response> 50 /// <response code="500">服务器错误</response>
50 Task<UsAppLocationDetailOutputDto> GetLocationDetailAsync(string locationId); 51 Task<UsAppLocationDetailOutputDto> GetLocationDetailAsync(string locationId);
  52 +
  53 + /// <summary>
  54 + /// App forgot password: send email verification code (same user store and rules as platform).
  55 + /// </summary>
  56 + /// <remarks>
  57 + /// When <c>RbacOptions.EnableCaptcha</c> is true, pass <c>Uuid</c> and <c>Code</c> from platform captcha API.
  58 + /// </remarks>
  59 + /// <param name="input">Email and optional image captcha.</param>
  60 + /// <response code="200">Accepted.</response>
  61 + /// <response code="400">Validation or rate limit.</response>
  62 + /// <response code="500">Server error</response>
  63 + Task PostSendForgotPasswordCodeByEmailAsync(EmailCaptchaImageDto input);
  64 +
  65 + /// <summary>
  66 + /// App forgot password: reset password with email OTP.
  67 + /// </summary>
  68 + /// <param name="input">Email, OTP, new password.</param>
  69 + /// <returns>Account user name.</returns>
  70 + /// <response code="200">Password updated.</response>
  71 + /// <response code="400">Invalid code or password policy.</response>
  72 + /// <response code="500">Server error</response>
  73 + Task<string> PostResetPasswordByEmailAsync(RetrievePasswordByEmailDto input);
51 } 74 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj
@@ -9,6 +9,7 @@ @@ -9,6 +9,7 @@
9 9
10 <ItemGroup> 10 <ItemGroup>
11 <ProjectReference Include="..\..\..\framework\Yi.Framework.Ddd.Application\Yi.Framework.Ddd.Application.csproj" /> 11 <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" />
12 <ProjectReference Include="..\FoodLabeling.Application.Contracts\FoodLabeling.Application.Contracts.csproj" /> 13 <ProjectReference Include="..\FoodLabeling.Application.Contracts\FoodLabeling.Application.Contracts.csproj" />
13 <ProjectReference Include="..\FoodLabeling.Domain\FoodLabeling.Domain.csproj" /> 14 <ProjectReference Include="..\FoodLabeling.Domain\FoodLabeling.Domain.csproj" />
14 <ProjectReference Include="..\..\rbac\Yi.Framework.Rbac.Domain\Yi.Framework.Rbac.Domain.csproj" /> 15 <ProjectReference Include="..\..\rbac\Yi.Framework.Rbac.Domain\Yi.Framework.Rbac.Domain.csproj" />
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs
@@ -50,6 +50,12 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService @@ -50,6 +50,12 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
50 throw new UserFriendlyException("用户不存在"); 50 throw new UserFriendlyException("用户不存在");
51 } 51 }
52 52
  53 + var userRoleIds = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity>()
  54 + .Where(x => x.UserId == userId)
  55 + .Select(x => x.RoleId)
  56 + .ToListAsync();
  57 + var distinctUserRoleIds = userRoleIds.Distinct().ToList();
  58 +
53 List<MenuDbEntity> menus; 59 List<MenuDbEntity> menus;
54 if (UserConst.Admin.Equals(user.UserName)) 60 if (UserConst.Admin.Equals(user.UserName))
55 { 61 {
@@ -60,12 +66,7 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService @@ -60,12 +66,7 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
60 } 66 }
61 else 67 else
62 { 68 {
63 - var roleIds = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity>()  
64 - .Where(x => x.UserId == userId)  
65 - .Select(x => x.RoleId)  
66 - .ToListAsync();  
67 -  
68 - var roleIdStrs = roleIds.Select(x => x.ToString()).Distinct().ToList(); 69 + var roleIdStrs = distinctUserRoleIds.Select(x => x.ToString()).Distinct().ToList();
69 if (roleIdStrs.Count == 0) 70 if (roleIdStrs.Count == 0)
70 { 71 {
71 menus = new List<MenuDbEntity>(); 72 menus = new List<MenuDbEntity>();
@@ -92,10 +93,11 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService @@ -92,10 +93,11 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
92 .ThenBy(x => x.MenuName) 93 .ThenBy(x => x.MenuName)
93 .ToList(); 94 .ToList();
94 95
95 - // 注意:查询 RoleAggregateRoot 会触发 YiRbacDbContext 的 IDataPermission 过滤, 96 + // 注意:经仓储查询 RoleAggregateRoot 会触发 YiRbacDbContext 的 IDataPermission 过滤,
96 // 其表达式包含 roleInfo.Select(...).Contains(...),在当前 SqlSugar 版本下会报“不支持 Select”。 97 // 其表达式包含 roleInfo.Select(...).Contains(...),在当前 SqlSugar 版本下会报“不支持 Select”。
97 - // 这里直接使用 JWT 中的角色码(CurrentUser.Roles)返回,避免触发过滤器 98 + // 角色展示名使用 RoleDbEntity 直查 Role 表;角色编码列表仍用 JWT(CurrentUser.Roles),与原先 RoleCodes 行为一致
98 var roleCodes = CurrentUser.Roles?.ToList() ?? new List<string>(); 99 var roleCodes = CurrentUser.Roles?.ToList() ?? new List<string>();
  100 + var roleDisplay = await BuildRoleDisplayAsync(distinctUserRoleIds, roleCodes);
99 101
100 var permissionCodes = menuNodes 102 var permissionCodes = menuNodes
101 .Where(x => !string.IsNullOrWhiteSpace(x.PermissionCode)) 103 .Where(x => !string.IsNullOrWhiteSpace(x.PermissionCode))
@@ -116,7 +118,10 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService @@ -116,7 +118,10 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
116 }, 118 },
117 RoleCodes = roleCodes.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().OrderBy(x => x).ToList(), 119 RoleCodes = roleCodes.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().OrderBy(x => x).ToList(),
118 PermissionCodes = permissionCodes, 120 PermissionCodes = permissionCodes,
119 - Menus = BuildMenuTree(menuNodes) 121 + Menus = BuildMenuTree(menuNodes),
  122 + LastUpdated = user.LastModificationTime,
  123 + Role = roleDisplay,
  124 + FullName = BuildFullName(user)
120 }; 125 };
121 } 126 }
122 127
@@ -133,6 +138,67 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService @@ -133,6 +138,67 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
133 return true; 138 return true;
134 } 139 }
135 140
  141 + private static string BuildFullName(UserAggregateRoot user)
  142 + {
  143 + if (!string.IsNullOrWhiteSpace(user.Name))
  144 + {
  145 + return user.Name.Trim();
  146 + }
  147 +
  148 + if (!string.IsNullOrWhiteSpace(user.Nick))
  149 + {
  150 + return user.Nick.Trim();
  151 + }
  152 +
  153 + return user.UserName ?? string.Empty;
  154 + }
  155 +
  156 + private async Task<string> BuildRoleDisplayAsync(
  157 + IReadOnlyList<Guid> userRoleIds,
  158 + IReadOnlyList<string> jwtRoleCodes)
  159 + {
  160 + var names = new List<string>();
  161 + if (userRoleIds.Count > 0)
  162 + {
  163 + names = await _dbContext.SqlSugarClient.Queryable<RoleDbEntity>()
  164 + .Where(r => !r.IsDeleted && userRoleIds.Contains(r.Id))
  165 + .OrderBy(r => r.RoleName)
  166 + .Select(r => r.RoleName)
  167 + .ToListAsync();
  168 + }
  169 +
  170 + if (names.Count == 0 && jwtRoleCodes.Count > 0)
  171 + {
  172 + var codes = jwtRoleCodes
  173 + .Where(x => !string.IsNullOrWhiteSpace(x))
  174 + .Select(x => x.Trim())
  175 + .Distinct()
  176 + .ToList();
  177 + names = await _dbContext.SqlSugarClient.Queryable<RoleDbEntity>()
  178 + .Where(r => !r.IsDeleted && codes.Contains(r.RoleCode))
  179 + .OrderBy(r => r.RoleName)
  180 + .Select(r => r.RoleName)
  181 + .ToListAsync();
  182 + }
  183 +
  184 + var distinctNames = names
  185 + .Where(n => !string.IsNullOrWhiteSpace(n))
  186 + .Select(n => n.Trim())
  187 + .Distinct()
  188 + .ToList();
  189 + if (distinctNames.Count > 0)
  190 + {
  191 + return string.Join(", ", distinctNames);
  192 + }
  193 +
  194 + var fallbackCodes = jwtRoleCodes
  195 + .Where(x => !string.IsNullOrWhiteSpace(x))
  196 + .Select(x => x.Trim())
  197 + .Distinct()
  198 + .ToList();
  199 + return fallbackCodes.Count > 0 ? string.Join(", ", fallbackCodes) : string.Empty;
  200 + }
  201 +
136 private static List<CurrentUserMenuNodeDto> BuildMenuTree(List<CurrentUserMenuNodeDto> flat) 202 private static List<CurrentUserMenuNodeDto> BuildMenuTree(List<CurrentUserMenuNodeDto> flat)
137 { 203 {
138 var nodes = flat 204 var nodes = flat
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlPartnerDbEntity.cs
@@ -36,6 +36,21 @@ public class FlPartnerDbEntity @@ -36,6 +36,21 @@ public class FlPartnerDbEntity
36 /// </summary> 36 /// </summary>
37 public string? PhoneNumber { get; set; } 37 public string? PhoneNumber { get; set; }
38 38
  39 + public string? Street { get; set; }
  40 +
  41 + public string? City { get; set; }
  42 +
  43 + /// <summary>
  44 + /// 州/省代码(如 NY);勿与启用状态字段 <see cref="State"/> 混淆
  45 + /// </summary>
  46 + [SugarColumn(ColumnName = "StateCode")]
  47 + public string? StateCode { get; set; }
  48 +
  49 + public string? Country { get; set; }
  50 +
  51 + [SugarColumn(ColumnName = "ZipCode")]
  52 + public string? ZipCode { get; set; }
  53 +
39 /// <summary> 54 /// <summary>
40 /// 是否启用(对应 UI Active) 55 /// 是否启用(对应 UI Active)
41 /// </summary> 56 /// </summary>
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs
@@ -36,13 +36,13 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -36,13 +36,13 @@ public class GroupAppService : ApplicationService, IGroupAppService
36 public async Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input) 36 public async Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input)
37 { 37 {
38 RefAsync<int> total = 0; 38 RefAsync<int> total = 0;
39 - var query = BuildGroupJoinedQuery(input); 39 + var query = await BuildGroupJoinedQueryAsync(input);
40 var projected = query.Select((g, p) => new GroupGetListOutputDto 40 var projected = query.Select((g, p) => new GroupGetListOutputDto
41 { 41 {
42 Id = g.Id, 42 Id = g.Id,
43 GroupName = g.GroupName, 43 GroupName = g.GroupName,
44 PartnerId = g.PartnerId, 44 PartnerId = g.PartnerId,
45 - PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "" : p.PartnerName.Trim(), 45 + PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "None" : p.PartnerName.Trim(),
46 State = g.State, 46 State = g.State,
47 CreationTime = g.CreationTime 47 CreationTime = g.CreationTime
48 }); 48 });
@@ -52,19 +52,19 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -52,19 +52,19 @@ public class GroupAppService : ApplicationService, IGroupAppService
52 } 52 }
53 53
54 /// <inheritdoc /> 54 /// <inheritdoc />
55 - public async Task<GroupGetOutputDto> GetAsync(string id) 55 + public async Task<GroupGetOutputDto> GetAsync(Guid id)
56 { 56 {
57 - var groupId = id?.Trim();  
58 - if (string.IsNullOrWhiteSpace(groupId)) 57 + if (id == Guid.Empty)
59 { 58 {
60 - throw new UserFriendlyException("组织Id不能为空"); 59 + throw new UserFriendlyException("Group id is required.");
61 } 60 }
62 61
  62 + var groupId = id.ToString();
63 var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>() 63 var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
64 .FirstAsync(x => !x.IsDeleted && x.Id == groupId); 64 .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
65 if (entity is null) 65 if (entity is null)
66 { 66 {
67 - throw new UserFriendlyException("组织不存在"); 67 + throw new UserFriendlyException("Group not found.");
68 } 68 }
69 69
70 var partnerName = await ResolvePartnerNameAsync(entity.PartnerId); 70 var partnerName = await ResolvePartnerNameAsync(entity.PartnerId);
@@ -78,13 +78,13 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -78,13 +78,13 @@ public class GroupAppService : ApplicationService, IGroupAppService
78 var name = input.GroupName?.Trim(); 78 var name = input.GroupName?.Trim();
79 if (string.IsNullOrWhiteSpace(name)) 79 if (string.IsNullOrWhiteSpace(name))
80 { 80 {
81 - throw new UserFriendlyException("组织名称不能为空"); 81 + throw new UserFriendlyException("Region name is required.");
82 } 82 }
83 83
84 var partnerId = input.PartnerId?.Trim(); 84 var partnerId = input.PartnerId?.Trim();
85 if (string.IsNullOrWhiteSpace(partnerId)) 85 if (string.IsNullOrWhiteSpace(partnerId))
86 { 86 {
87 - throw new UserFriendlyException("请选择所属合作伙伴"); 87 + throw new UserFriendlyException("Parent company is required.");
88 } 88 }
89 89
90 await EnsurePartnerExistsAsync(partnerId); 90 await EnsurePartnerExistsAsync(partnerId);
@@ -104,36 +104,36 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -104,36 +104,36 @@ public class GroupAppService : ApplicationService, IGroupAppService
104 }; 104 };
105 105
106 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); 106 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
107 - return await GetAsync(entity.Id); 107 + return await GetAsync(Guid.Parse(entity.Id));
108 } 108 }
109 109
110 /// <inheritdoc /> 110 /// <inheritdoc />
111 [UnitOfWork] 111 [UnitOfWork]
112 - public async Task<GroupGetOutputDto> UpdateAsync(string id, GroupUpdateInputVo input) 112 + public async Task<GroupGetOutputDto> UpdateAsync(Guid id, GroupUpdateInputVo input)
113 { 113 {
114 - var groupId = id?.Trim();  
115 - if (string.IsNullOrWhiteSpace(groupId)) 114 + if (id == Guid.Empty)
116 { 115 {
117 - throw new UserFriendlyException("组织Id不能为空"); 116 + throw new UserFriendlyException("Group id is required.");
118 } 117 }
119 118
  119 + var groupId = id.ToString();
120 var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>() 120 var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
121 .FirstAsync(x => !x.IsDeleted && x.Id == groupId); 121 .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
122 if (entity is null) 122 if (entity is null)
123 { 123 {
124 - throw new UserFriendlyException("组织不存在"); 124 + throw new UserFriendlyException("Group not found.");
125 } 125 }
126 126
127 var name = input.GroupName?.Trim(); 127 var name = input.GroupName?.Trim();
128 if (string.IsNullOrWhiteSpace(name)) 128 if (string.IsNullOrWhiteSpace(name))
129 { 129 {
130 - throw new UserFriendlyException("组织名称不能为空"); 130 + throw new UserFriendlyException("Region name is required.");
131 } 131 }
132 132
133 var partnerId = input.PartnerId?.Trim(); 133 var partnerId = input.PartnerId?.Trim();
134 if (string.IsNullOrWhiteSpace(partnerId)) 134 if (string.IsNullOrWhiteSpace(partnerId))
135 { 135 {
136 - throw new UserFriendlyException("请选择所属合作伙伴"); 136 + throw new UserFriendlyException("Parent company is required.");
137 } 137 }
138 138
139 await EnsurePartnerExistsAsync(partnerId); 139 await EnsurePartnerExistsAsync(partnerId);
@@ -145,24 +145,24 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -145,24 +145,24 @@ public class GroupAppService : ApplicationService, IGroupAppService
145 entity.LastModifierId = CurrentUser?.Id?.ToString(); 145 entity.LastModifierId = CurrentUser?.Id?.ToString();
146 146
147 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); 147 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
148 - return await GetAsync(groupId); 148 + return await GetAsync(id);
149 } 149 }
150 150
151 /// <inheritdoc /> 151 /// <inheritdoc />
152 [UnitOfWork] 152 [UnitOfWork]
153 - public async Task DeleteAsync(string id) 153 + public async Task DeleteAsync(Guid id)
154 { 154 {
155 - var groupId = id?.Trim();  
156 - if (string.IsNullOrWhiteSpace(groupId)) 155 + if (id == Guid.Empty)
157 { 156 {
158 - throw new UserFriendlyException("组织Id不能为空"); 157 + throw new UserFriendlyException("Group id is required.");
159 } 158 }
160 159
  160 + var groupId = id.ToString();
161 var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>() 161 var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
162 .FirstAsync(x => !x.IsDeleted && x.Id == groupId); 162 .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
163 if (entity is null) 163 if (entity is null)
164 { 164 {
165 - throw new UserFriendlyException("组织不存在"); 165 + throw new UserFriendlyException("Group not found.");
166 } 166 }
167 167
168 entity.IsDeleted = true; 168 entity.IsDeleted = true;
@@ -172,24 +172,26 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -172,24 +172,26 @@ public class GroupAppService : ApplicationService, IGroupAppService
172 } 172 }
173 173
174 /// <inheritdoc /> 174 /// <inheritdoc />
175 - public async Task<IActionResult> ExportPdfAsync(GroupGetListInputVo input) 175 + [HttpGet]
  176 + public async Task<IActionResult> ExportPdfAsync([FromQuery] GroupGetListInputVo input)
176 { 177 {
177 QuestPDF.Settings.License = LicenseType.Community; 178 QuestPDF.Settings.License = LicenseType.Community;
178 179
179 - var exportBase = BuildGroupJoinedQuery(input); 180 + var exportBase = await BuildGroupJoinedQueryAsync(input);
180 var count = await exportBase.CountAsync(); 181 var count = await exportBase.CountAsync();
181 if (count > ExportPdfMaxRows) 182 if (count > ExportPdfMaxRows)
182 { 183 {
183 - throw new UserFriendlyException($"导出数据超过上限 {ExportPdfMaxRows} 条,请缩小筛选范围"); 184 + throw new UserFriendlyException(
  185 + $"Export exceeds the maximum of {ExportPdfMaxRows} rows. Narrow your filters and try again.");
184 } 186 }
185 187
186 - var rows = await BuildGroupJoinedQuery(input) 188 + var rows = await (await BuildGroupJoinedQueryAsync(input))
187 .Select((g, p) => new GroupGetListOutputDto 189 .Select((g, p) => new GroupGetListOutputDto
188 { 190 {
189 Id = g.Id, 191 Id = g.Id,
190 GroupName = g.GroupName, 192 GroupName = g.GroupName,
191 PartnerId = g.PartnerId, 193 PartnerId = g.PartnerId,
192 - PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "" : p.PartnerName.Trim(), 194 + PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "None" : p.PartnerName.Trim(),
193 State = g.State, 195 State = g.State,
194 CreationTime = g.CreationTime 196 CreationTime = g.CreationTime
195 }) 197 })
@@ -228,7 +230,7 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -228,7 +230,7 @@ public class GroupAppService : ApplicationService, IGroupAppService
228 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5) 230 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
229 .Text(e.GroupName ?? string.Empty); 231 .Text(e.GroupName ?? string.Empty);
230 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5) 232 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
231 - .Text(string.IsNullOrWhiteSpace(e.PartnerName) ? "" : e.PartnerName); 233 + .Text(string.IsNullOrWhiteSpace(e.PartnerName) ? "None" : e.PartnerName);
232 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status); 234 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status);
233 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5) 235 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
234 .Text(e.CreationTime.ToString("yyyy-MM-dd HH:mm")); 236 .Text(e.CreationTime.ToString("yyyy-MM-dd HH:mm"));
@@ -243,14 +245,21 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -243,14 +245,21 @@ public class GroupAppService : ApplicationService, IGroupAppService
243 return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName }; 245 return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
244 } 246 }
245 247
246 - private ISugarQueryable<FlGroupDbEntity, FlPartnerDbEntity> BuildGroupJoinedQuery(GroupGetListInputVo input) 248 + private async Task<ISugarQueryable<FlGroupDbEntity, FlPartnerDbEntity>> BuildGroupJoinedQueryAsync(
  249 + GroupGetListInputVo input)
247 { 250 {
  251 + var scope = await PartnerScopeHelper.ResolveGroupScopeAsync(CurrentUser, _dbContext);
  252 +
248 var keyword = input.Keyword?.Trim(); 253 var keyword = input.Keyword?.Trim();
249 var partnerId = input.PartnerId?.Trim(); 254 var partnerId = input.PartnerId?.Trim();
250 255
251 var query = _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>() 256 var query = _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
252 .LeftJoin<FlPartnerDbEntity>((g, p) => g.PartnerId == p.Id && !p.IsDeleted) 257 .LeftJoin<FlPartnerDbEntity>((g, p) => g.PartnerId == p.Id && !p.IsDeleted)
253 - .Where((g, p) => !g.IsDeleted) 258 + .Where((g, p) => !g.IsDeleted);
  259 +
  260 + query = PartnerScopeHelper.ApplyGroupScope(query, scope);
  261 +
  262 + query = query
254 .WhereIF(input.State != null, (g, p) => g.State == input.State) 263 .WhereIF(input.State != null, (g, p) => g.State == input.State)
255 .WhereIF(!string.IsNullOrWhiteSpace(partnerId), (g, p) => g.PartnerId == partnerId) 264 .WhereIF(!string.IsNullOrWhiteSpace(partnerId), (g, p) => g.PartnerId == partnerId)
256 .WhereIF(!string.IsNullOrWhiteSpace(keyword), 265 .WhereIF(!string.IsNullOrWhiteSpace(keyword),
@@ -311,7 +320,7 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -311,7 +320,7 @@ public class GroupAppService : ApplicationService, IGroupAppService
311 .AnyAsync(x => !x.IsDeleted && x.Id == partnerId); 320 .AnyAsync(x => !x.IsDeleted && x.Id == partnerId);
312 if (!ok) 321 if (!ok)
313 { 322 {
314 - throw new UserFriendlyException("所选合作伙伴不存在或已删除"); 323 + throw new UserFriendlyException("The selected company does not exist or has been removed.");
315 } 324 }
316 } 325 }
317 326
@@ -321,7 +330,7 @@ public class GroupAppService : ApplicationService, IGroupAppService @@ -321,7 +330,7 @@ public class GroupAppService : ApplicationService, IGroupAppService
321 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId); 330 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
322 if (p is null || string.IsNullOrWhiteSpace(p.PartnerName)) 331 if (p is null || string.IsNullOrWhiteSpace(p.PartnerName))
323 { 332 {
324 - return ""; 333 + return "None";
325 } 334 }
326 335
327 return p.PartnerName.Trim(); 336 return p.PartnerName.Trim();
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs
@@ -34,6 +34,7 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -34,6 +34,7 @@ public class LabelAppService : ApplicationService, ILabelAppService
34 RefAsync<int> total = 0; 34 RefAsync<int> total = 0;
35 35
36 var productId = input.ProductId?.Trim(); 36 var productId = input.ProductId?.Trim();
  37 + var groupId = input.GroupId?.Trim();
37 var locationId = input.LocationId?.Trim(); 38 var locationId = input.LocationId?.Trim();
38 var keyword = input.Keyword?.Trim(); 39 var keyword = input.Keyword?.Trim();
39 var labelCategoryId = input.LabelCategoryId?.Trim(); 40 var labelCategoryId = input.LabelCategoryId?.Trim();
@@ -45,7 +46,6 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -45,7 +46,6 @@ public class LabelAppService : ApplicationService, ILabelAppService
45 46
46 var labelIdsQuery = _dbContext.SqlSugarClient.Queryable<FlLabelDbEntity>() 47 var labelIdsQuery = _dbContext.SqlSugarClient.Queryable<FlLabelDbEntity>()
47 .Where(l => !l.IsDeleted) 48 .Where(l => !l.IsDeleted)
48 - .WhereIF(!string.IsNullOrWhiteSpace(locationId), l => l.LocationId == locationId)  
49 .WhereIF(!string.IsNullOrWhiteSpace(labelCategoryId), l => l.LabelCategoryId == labelCategoryId) 49 .WhereIF(!string.IsNullOrWhiteSpace(labelCategoryId), l => l.LabelCategoryId == labelCategoryId)
50 .WhereIF(!string.IsNullOrWhiteSpace(labelTypeId), l => l.LabelTypeId == labelTypeId) 50 .WhereIF(!string.IsNullOrWhiteSpace(labelTypeId), l => l.LabelTypeId == labelTypeId)
51 .WhereIF(input.State != null, l => l.State == input.State); 51 .WhereIF(input.State != null, l => l.State == input.State);
@@ -58,6 +58,15 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -58,6 +58,15 @@ public class LabelAppService : ApplicationService, ILabelAppService
58 .Select((l, tpl) => l); 58 .Select((l, tpl) => l);
59 } 59 }
60 60
  61 + var scopedLocationIds = await LocationScopeBindingHelper.ResolveScopedLocationIdsAsync(
  62 + _dbContext.SqlSugarClient, groupId, locationId);
  63 + if (scopedLocationIds is not null)
  64 + {
  65 + labelIdsQuery = scopedLocationIds.Count == 0
  66 + ? labelIdsQuery.Where(_ => false)
  67 + : labelIdsQuery.Where(l => scopedLocationIds.Contains(l.LocationId));
  68 + }
  69 +
61 // 按产品筛选:存在 label-product 关联即可 70 // 按产品筛选:存在 label-product 关联即可
62 if (!string.IsNullOrWhiteSpace(productId)) 71 if (!string.IsNullOrWhiteSpace(productId))
63 { 72 {
@@ -259,12 +268,25 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -259,12 +268,25 @@ public class LabelAppService : ApplicationService, ILabelAppService
259 labelInfo = JsonSerializer.Deserialize<object>(label.LabelInfoJson); 268 labelInfo = JsonSerializer.Deserialize<object>(label.LabelInfoJson);
260 } 269 }
261 270
  271 + var locationId = label.LocationId ?? string.Empty;
  272 + var locationIdList = string.IsNullOrWhiteSpace(locationId)
  273 + ? new List<string>()
  274 + : new List<string> { locationId.Trim() };
  275 + var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
  276 + _dbContext.SqlSugarClient, locationIdList);
  277 + var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
  278 + _dbContext.SqlSugarClient, locationIdList);
  279 +
262 return new LabelGetOutputDto 280 return new LabelGetOutputDto
263 { 281 {
264 Id = label.LabelCode ?? string.Empty, 282 Id = label.LabelCode ?? string.Empty,
265 LabelName = label.LabelName, 283 LabelName = label.LabelName,
266 - LocationId = label.LocationId ?? string.Empty, 284 + LocationId = locationId,
267 LocationName = location?.LocationName ?? location?.LocationCode ?? "无", 285 LocationName = location?.LocationName ?? location?.LocationCode ?? "无",
  286 + PartnerId = partnerIds.Count > 0 ? partnerIds[0] : null,
  287 + PartnerIds = partnerIds,
  288 + RegionIds = regionIds,
  289 + GroupIds = regionIds,
268 LabelCategoryId = label.LabelCategoryId ?? string.Empty, 290 LabelCategoryId = label.LabelCategoryId ?? string.Empty,
269 LabelCategoryName = category?.CategoryName ?? "无", 291 LabelCategoryName = category?.CategoryName ?? "无",
270 LabelTypeId = label.LabelTypeId ?? string.Empty, 292 LabelTypeId = label.LabelTypeId ?? string.Empty,
@@ -301,10 +323,6 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -301,10 +323,6 @@ public class LabelAppService : ApplicationService, ILabelAppService
301 { 323 {
302 throw new UserFriendlyException("模板编码不能为空"); 324 throw new UserFriendlyException("模板编码不能为空");
303 } 325 }
304 - if (string.IsNullOrWhiteSpace(input.LocationId))  
305 - {  
306 - throw new UserFriendlyException("门店Id不能为空");  
307 - }  
308 if (string.IsNullOrWhiteSpace(input.LabelCategoryId)) 326 if (string.IsNullOrWhiteSpace(input.LabelCategoryId))
309 { 327 {
310 throw new UserFriendlyException("标签分类Id不能为空"); 328 throw new UserFriendlyException("标签分类Id不能为空");
@@ -314,6 +332,8 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -314,6 +332,8 @@ public class LabelAppService : ApplicationService, ILabelAppService
314 throw new UserFriendlyException("标签类型Id不能为空"); 332 throw new UserFriendlyException("标签类型Id不能为空");
315 } 333 }
316 334
  335 + var resolvedLocationId = await ResolveLabelLocationIdForSaveAsync(input);
  336 +
317 var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>() 337 var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
318 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim()); 338 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim());
319 if (template is null) 339 if (template is null)
@@ -344,7 +364,7 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -344,7 +364,7 @@ public class LabelAppService : ApplicationService, ILabelAppService
344 LabelCode = labelCode, 364 LabelCode = labelCode,
345 LabelName = labelName, 365 LabelName = labelName,
346 TemplateId = template.Id, 366 TemplateId = template.Id,
347 - LocationId = input.LocationId?.Trim(), 367 + LocationId = resolvedLocationId,
348 LabelCategoryId = input.LabelCategoryId?.Trim(), 368 LabelCategoryId = input.LabelCategoryId?.Trim(),
349 LabelTypeId = input.LabelTypeId?.Trim(), 369 LabelTypeId = input.LabelTypeId?.Trim(),
350 State = input.State, 370 State = input.State,
@@ -363,6 +383,9 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -363,6 +383,9 @@ public class LabelAppService : ApplicationService, ILabelAppService
363 }).ToList(); 383 }).ToList();
364 await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync(); 384 await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync();
365 385
  386 + await LabelCategoryAppService.TouchLabelCategoryLastEditedAsync(
  387 + _dbContext, labelEntity.LabelCategoryId, CurrentUser?.Id?.ToString());
  388 +
366 return await GetAsync(labelCode); 389 return await GetAsync(labelCode);
367 } 390 }
368 391
@@ -391,10 +414,6 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -391,10 +414,6 @@ public class LabelAppService : ApplicationService, ILabelAppService
391 { 414 {
392 throw new UserFriendlyException("模板编码不能为空"); 415 throw new UserFriendlyException("模板编码不能为空");
393 } 416 }
394 - if (string.IsNullOrWhiteSpace(input.LocationId))  
395 - {  
396 - throw new UserFriendlyException("门店Id不能为空");  
397 - }  
398 if (string.IsNullOrWhiteSpace(input.LabelCategoryId)) 417 if (string.IsNullOrWhiteSpace(input.LabelCategoryId))
399 { 418 {
400 throw new UserFriendlyException("标签分类Id不能为空"); 419 throw new UserFriendlyException("标签分类Id不能为空");
@@ -404,6 +423,8 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -404,6 +423,8 @@ public class LabelAppService : ApplicationService, ILabelAppService
404 throw new UserFriendlyException("标签类型Id不能为空"); 423 throw new UserFriendlyException("标签类型Id不能为空");
405 } 424 }
406 425
  426 + var resolvedLocationId = await ResolveLabelLocationIdForSaveAsync(input);
  427 +
407 var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>() 428 var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
408 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim()); 429 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim());
409 if (template is null) 430 if (template is null)
@@ -411,10 +432,11 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -411,10 +432,11 @@ public class LabelAppService : ApplicationService, ILabelAppService
411 throw new UserFriendlyException("模板不存在"); 432 throw new UserFriendlyException("模板不存在");
412 } 433 }
413 434
  435 + var oldCategoryId = label.LabelCategoryId;
414 var now = DateTime.Now; 436 var now = DateTime.Now;
415 label.LabelName = input.LabelName?.Trim() ?? label.LabelName; 437 label.LabelName = input.LabelName?.Trim() ?? label.LabelName;
416 label.TemplateId = template.Id; 438 label.TemplateId = template.Id;
417 - label.LocationId = input.LocationId?.Trim(); 439 + label.LocationId = resolvedLocationId;
418 label.LabelCategoryId = input.LabelCategoryId?.Trim(); 440 label.LabelCategoryId = input.LabelCategoryId?.Trim();
419 label.LabelTypeId = input.LabelTypeId?.Trim(); 441 label.LabelTypeId = input.LabelTypeId?.Trim();
420 label.State = input.State; 442 label.State = input.State;
@@ -438,6 +460,15 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -438,6 +460,15 @@ public class LabelAppService : ApplicationService, ILabelAppService
438 }).ToList(); 460 }).ToList();
439 await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync(); 461 await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync();
440 462
  463 + var newCategoryId = label.LabelCategoryId;
  464 + await LabelCategoryAppService.TouchLabelCategoryLastEditedAsync(
  465 + _dbContext, newCategoryId, CurrentUser?.Id?.ToString());
  466 + if (!string.Equals(oldCategoryId, newCategoryId, StringComparison.Ordinal))
  467 + {
  468 + await LabelCategoryAppService.TouchLabelCategoryLastEditedAsync(
  469 + _dbContext, oldCategoryId, CurrentUser?.Id?.ToString());
  470 + }
  471 +
441 return await GetAsync(labelCode); 472 return await GetAsync(labelCode);
442 } 473 }
443 474
@@ -465,6 +496,9 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -465,6 +496,9 @@ public class LabelAppService : ApplicationService, ILabelAppService
465 await _dbContext.SqlSugarClient.Deleteable<FlLabelProductDbEntity>() 496 await _dbContext.SqlSugarClient.Deleteable<FlLabelProductDbEntity>()
466 .Where(x => x.LabelId == label.Id) 497 .Where(x => x.LabelId == label.Id)
467 .ExecuteCommandAsync(); 498 .ExecuteCommandAsync();
  499 +
  500 + await LabelCategoryAppService.TouchLabelCategoryLastEditedAsync(
  501 + _dbContext, label.LabelCategoryId, CurrentUser?.Id?.ToString());
468 } 502 }
469 503
470 /// <summary> 504 /// <summary>
@@ -730,5 +764,86 @@ public class LabelAppService : ApplicationService, ILabelAppService @@ -730,5 +764,86 @@ public class LabelAppService : ApplicationService, ILabelAppService
730 Elements = resolvedElements 764 Elements = resolvedElements
731 }; 765 };
732 } 766 }
  767 +
  768 + private Task<string> ResolveLabelLocationIdForSaveAsync(LabelUpdateInputVo input) =>
  769 + ResolveLabelLocationIdForSaveAsync(new LabelCreateInputVo
  770 + {
  771 + PartnerId = input.PartnerId,
  772 + PartnerIds = input.PartnerIds,
  773 + RegionIds = input.RegionIds,
  774 + GroupIds = input.GroupIds,
  775 + LocationId = input.LocationId,
  776 + LocationIds = input.LocationIds
  777 + });
  778 +
  779 + /// <summary>
  780 + /// 标签仅绑定单门店:解析 Company/Region/门店入参为唯一 <c>location.Id</c>。
  781 + /// </summary>
  782 + private async Task<string> ResolveLabelLocationIdForSaveAsync(LabelCreateInputVo input)
  783 + {
  784 + var partnerIds = NormalizePartnerIds(input);
  785 + var regionIds = NormalizeRegionIds(input);
  786 + var explicitLocationId = input.LocationId?.Trim();
  787 + var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds);
  788 +
  789 + var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
  790 + _dbContext.SqlSugarClient, partnerIds, regionIds, explicitLocationIds);
  791 +
  792 + if (!string.IsNullOrWhiteSpace(explicitLocationId))
  793 + {
  794 + if (merged.Count > 0 && !merged.Contains(explicitLocationId, StringComparer.Ordinal))
  795 + {
  796 + throw new UserFriendlyException("所选门店不在指定公司/区域范围内");
  797 + }
  798 +
  799 + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(
  800 + _dbContext.SqlSugarClient, new List<string> { explicitLocationId });
  801 + return explicitLocationId;
  802 + }
  803 +
  804 + if (merged.Count == 0)
  805 + {
  806 + throw new UserFriendlyException("须指定门店 locationId,或选择公司/区域以解析出门店");
  807 + }
  808 +
  809 + if (merged.Count > 1)
  810 + {
  811 + throw new UserFriendlyException("所选公司/区域对应多个门店,请显式指定 locationId");
  812 + }
  813 +
  814 + return merged[0];
  815 + }
  816 +
  817 + private static List<string> NormalizePartnerIds(LabelCreateInputVo input)
  818 + {
  819 + var merged = new HashSet<string>(StringComparer.Ordinal);
  820 + if (!string.IsNullOrWhiteSpace(input.PartnerId))
  821 + {
  822 + merged.Add(input.PartnerId.Trim());
  823 + }
  824 +
  825 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.PartnerIds))
  826 + {
  827 + merged.Add(id);
  828 + }
  829 +
  830 + return merged.OrderBy(x => x, StringComparer.Ordinal).ToList();
  831 + }
  832 +
  833 + private static List<string> NormalizeRegionIds(LabelCreateInputVo input)
  834 + {
  835 + var merged = new HashSet<string>(StringComparer.Ordinal);
  836 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.RegionIds))
  837 + {
  838 + merged.Add(id);
  839 + }
  840 +
  841 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.GroupIds))
  842 + {
  843 + merged.Add(id);
  844 + }
  845 +
  846 + return merged.OrderBy(x => x, StringComparer.Ordinal).ToList();
  847 + }
733 } 848 }
734 849
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelCategoryAppService.cs
@@ -3,6 +3,7 @@ using FoodLabeling.Application.Contracts.Dtos.Common; @@ -3,6 +3,7 @@ using FoodLabeling.Application.Contracts.Dtos.Common;
3 using FoodLabeling.Application.Contracts.Dtos.LabelCategory; 3 using FoodLabeling.Application.Contracts.Dtos.LabelCategory;
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 FoodLabeling.Domain.Entities;
6 using SqlSugar; 7 using SqlSugar;
7 using Volo.Abp; 8 using Volo.Abp;
8 using Volo.Abp.Application.Services; 9 using Volo.Abp.Application.Services;
@@ -34,6 +35,8 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ @@ -34,6 +35,8 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
34 (x.DisplayText != null && x.DisplayText.Contains(keyword!))) 35 (x.DisplayText != null && x.DisplayText.Contains(keyword!)))
35 .WhereIF(input.State != null, x => x.State == input.State); 36 .WhereIF(input.State != null, x => x.State == input.State);
36 37
  38 + query = await ApplyCategoryScopeFilterAsync(query, input.GroupId, input.LocationId);
  39 +
37 // Sorting 仅允许白名单字段,避免 Unknown column/注入风险 40 // Sorting 仅允许白名单字段,避免 Unknown column/注入风险
38 if (!string.IsNullOrWhiteSpace(input.Sorting)) 41 if (!string.IsNullOrWhiteSpace(input.Sorting))
39 { 42 {
@@ -67,57 +70,33 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ @@ -67,57 +70,33 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
67 var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total); 70 var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
68 var ids = entities.Select(x => x.Id).ToList(); 71 var ids = entities.Select(x => x.Id).ToList();
69 72
70 - Dictionary<string, List<string>> locationIdsMap = new();  
71 - if (ids.Count > 0)  
72 - {  
73 - var locRows = await _dbContext.SqlSugarClient.Queryable<FlLabelCategoryLocationDbEntity>()  
74 - .Where(x => ids.Contains(x.CategoryId))  
75 - .Select(x => new { x.CategoryId, x.LocationId })  
76 - .ToListAsync();  
77 - foreach (var row in locRows)  
78 - {  
79 - var cid = row.CategoryId?.Trim();  
80 - var lid = row.LocationId?.Trim();  
81 - if (string.IsNullOrWhiteSpace(cid) || string.IsNullOrWhiteSpace(lid))  
82 - {  
83 - continue;  
84 - }  
85 -  
86 - if (!locationIdsMap.TryGetValue(cid, out var list))  
87 - {  
88 - list = new List<string>();  
89 - locationIdsMap[cid] = list;  
90 - } 73 + var labelStatsMap = await BuildCategoryLabelStatsMapAsync(ids);
  74 + var scopeMap = await BuildCategoryScopeMapAsync(entities);
91 75
92 - if (!list.Contains(lid))  
93 - {  
94 - list.Add(lid);  
95 - }  
96 - }  
97 - }  
98 -  
99 - var countRows = await _dbContext.SqlSugarClient.Queryable<FlLabelDbEntity>()  
100 - .Where(x => !x.IsDeleted)  
101 - .Where(x => x.LabelCategoryId != null && ids.Contains(x.LabelCategoryId))  
102 - .GroupBy(x => x.LabelCategoryId)  
103 - .Select(x => new { CategoryId = x.LabelCategoryId, Count = SqlFunc.AggregateCount(x.Id) })  
104 - .ToListAsync();  
105 - var countMap = countRows.ToDictionary(x => x.CategoryId!, x => (long)x.Count);  
106 -  
107 - var items = entities.Select(x => new LabelCategoryGetListOutputDto 76 + var items = entities.Select(x =>
108 { 77 {
109 - Id = x.Id,  
110 - CategoryCode = x.CategoryCode,  
111 - CategoryName = x.CategoryName,  
112 - DisplayText = x.DisplayText,  
113 - CategoryPhotoUrl = x.CategoryPhotoUrl,  
114 - State = x.State,  
115 - ButtonAppearance = x.ButtonAppearance,  
116 - AvailabilityType = x.AvailabilityType,  
117 - LocationIds = locationIdsMap.TryGetValue(x.Id, out var lids) ? lids : new List<string>(),  
118 - OrderNum = x.OrderNum,  
119 - NoOfLabels = countMap.TryGetValue(x.Id, out var count) ? count : 0,  
120 - LastEdited = x.LastModificationTime ?? x.CreationTime 78 + scopeMap.TryGetValue(x.Id, out var scope);
  79 + labelStatsMap.TryGetValue(x.Id, out var labelStats);
  80 + var creationTime = ResolveCategoryCreationTime(x);
  81 + return new LabelCategoryGetListOutputDto
  82 + {
  83 + Id = x.Id,
  84 + CategoryCode = x.CategoryCode,
  85 + CategoryName = x.CategoryName,
  86 + DisplayText = x.DisplayText,
  87 + CategoryPhotoUrl = x.CategoryPhotoUrl,
  88 + State = x.State,
  89 + ButtonAppearance = x.ButtonAppearance,
  90 + AvailabilityType = x.AvailabilityType,
  91 + OrderNum = x.OrderNum,
  92 + NoOfLabels = labelStats?.Count ?? 0,
  93 + CreationTime = creationTime,
  94 + LastEdited = ResolveLastEdited(x, creationTime, labelStats),
  95 + Region = scope?.Region ?? string.Empty,
  96 + Location = scope?.Location ?? string.Empty,
  97 + RegionIds = scope?.RegionIds ?? new List<string>(),
  98 + LocationIds = scope?.LocationIds ?? new List<string>()
  99 + };
121 }).ToList(); 100 }).ToList();
122 101
123 return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items); 102 return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
@@ -139,7 +118,11 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ @@ -139,7 +118,11 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
139 .Where(x => x.CategoryId == entity.Id) 118 .Where(x => x.CategoryId == entity.Id)
140 .Select(x => x.LocationId) 119 .Select(x => x.LocationId)
141 .ToListAsync(); 120 .ToListAsync();
142 - dto.LocationIds = locationIds?.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToList() ?? new(); 121 + dto.LocationIds = LocationScopeBindingHelper.NormalizeIds(locationIds);
  122 + var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
  123 + _dbContext.SqlSugarClient, dto.LocationIds);
  124 + dto.RegionIds = regionIds;
  125 + dto.GroupIds = regionIds;
143 } 126 }
144 127
145 return dto; 128 return dto;
@@ -156,9 +139,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ @@ -156,9 +139,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
156 139
157 var displayText = input.DisplayText?.Trim(); 140 var displayText = input.DisplayText?.Trim();
158 var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); 141 var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance);
159 - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant();  
160 - var locationIds = NormalizeLocationIds(input.LocationIds);  
161 - ValidateAvailabilityTypeAndLocations(availabilityType, locationIds); 142 + var (availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input);
162 143
163 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlLabelCategoryDbEntity>() 144 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlLabelCategoryDbEntity>()
164 .AnyAsync(x => !x.IsDeleted && (x.CategoryCode == code || x.CategoryName == name)); 145 .AnyAsync(x => !x.IsDeleted && (x.CategoryCode == code || x.CategoryName == name));
@@ -172,6 +153,12 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ @@ -172,6 +153,12 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
172 var entity = new FlLabelCategoryDbEntity 153 var entity = new FlLabelCategoryDbEntity
173 { 154 {
174 Id = _guidGenerator.Create().ToString(), 155 Id = _guidGenerator.Create().ToString(),
  156 + IsDeleted = false,
  157 + CreationTime = now,
  158 + CreatorId = currentUserId,
  159 + LastModificationTime = now,
  160 + LastModifierId = currentUserId,
  161 + ConcurrencyStamp = _guidGenerator.Create().ToString("N"),
175 CategoryCode = code, 162 CategoryCode = code,
176 CategoryName = name, 163 CategoryName = name,
177 DisplayText = string.IsNullOrWhiteSpace(displayText) ? null : displayText, 164 DisplayText = string.IsNullOrWhiteSpace(displayText) ? null : displayText,
@@ -183,7 +170,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ @@ -183,7 +170,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
183 }; 170 };
184 171
185 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); 172 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
186 - await SaveCategoryLocationsAsync(entity.Id, availabilityType, locationIds, currentUserId, now); 173 + await SaveCategoryLocationsAsync(entity.Id, availabilityType, mergedLocationIds, currentUserId, now);
187 return await GetAsync(entity.Id); 174 return await GetAsync(entity.Id);
188 } 175 }
189 176
@@ -205,9 +192,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ @@ -205,9 +192,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
205 192
206 var displayText = input.DisplayText?.Trim(); 193 var displayText = input.DisplayText?.Trim();
207 var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); 194 var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance);
208 - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant();  
209 - var locationIds = NormalizeLocationIds(input.LocationIds);  
210 - ValidateAvailabilityTypeAndLocations(availabilityType, locationIds); 195 + var (availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input);
211 196
212 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlLabelCategoryDbEntity>() 197 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlLabelCategoryDbEntity>()
213 .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.CategoryCode == code || x.CategoryName == name)); 198 .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.CategoryCode == code || x.CategoryName == name));
@@ -228,7 +213,8 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ @@ -228,7 +213,8 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
228 entity.LastModifierId = CurrentUser?.Id?.ToString(); 213 entity.LastModifierId = CurrentUser?.Id?.ToString();
229 214
230 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); 215 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
231 - await SaveCategoryLocationsAsync(entity.Id, availabilityType, locationIds, entity.LastModifierId, entity.LastModificationTime ?? DateTime.Now); 216 + await SaveCategoryLocationsAsync(entity.Id, availabilityType, mergedLocationIds, entity.LastModifierId,
  217 + entity.LastModificationTime ?? DateTime.Now);
232 return await GetAsync(id); 218 return await GetAsync(id);
233 } 219 }
234 220
@@ -270,26 +256,299 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ @@ -270,26 +256,299 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
270 }; 256 };
271 } 257 }
272 258
273 - private static void ValidateAvailabilityTypeAndLocations(string availabilityType, List<string> locationIds) 259 + private async Task<(string AvailabilityType, List<string> LocationIds)> ResolveCategoryScopeForSaveAsync(
  260 + LabelCategoryCreateInputVo input)
274 { 261 {
  262 + var regionIds = NormalizeRegionIds(input);
  263 + var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds);
  264 + var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant();
  265 +
  266 + var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null;
  267 + if (regionIds.Count > 0 || explicitLocationIds.Count > 0)
  268 + {
  269 + availabilityType = "SPECIFIED";
  270 + }
  271 + else if (hasScopeArrays && string.Equals(availabilityType, "ALL", StringComparison.OrdinalIgnoreCase))
  272 + {
  273 + availabilityType = "ALL";
  274 + }
  275 +
275 if (availabilityType != "ALL" && availabilityType != "SPECIFIED") 276 if (availabilityType != "ALL" && availabilityType != "SPECIFIED")
276 { 277 {
277 throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); 278 throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)");
278 } 279 }
279 280
280 - if (availabilityType == "SPECIFIED" && locationIds.Count == 0) 281 + if (availabilityType == "ALL")
281 { 282 {
282 - throw new UserFriendlyException("指定门店范围时必须至少选择一个门店"); 283 + return ("ALL", new List<string>());
283 } 284 }
  285 +
  286 + var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
  287 + _dbContext.SqlSugarClient, (IReadOnlyList<string>?)null, regionIds, explicitLocationIds);
  288 + if (merged.Count == 0)
  289 + {
  290 + throw new UserFriendlyException("指定适用区域或门店时,至少需要匹配到一个有效门店");
  291 + }
  292 +
  293 + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged);
  294 + return ("SPECIFIED", merged);
  295 + }
  296 +
  297 + private static List<string> NormalizeRegionIds(LabelCategoryCreateInputVo input)
  298 + {
  299 + var merged = new HashSet<string>(StringComparer.Ordinal);
  300 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.RegionIds))
  301 + {
  302 + merged.Add(id);
  303 + }
  304 +
  305 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.GroupIds))
  306 + {
  307 + merged.Add(id);
  308 + }
  309 +
  310 + return merged.OrderBy(x => x, StringComparer.Ordinal).ToList();
  311 + }
  312 +
  313 + private async Task<ISugarQueryable<FlLabelCategoryDbEntity>> ApplyCategoryScopeFilterAsync(
  314 + ISugarQueryable<FlLabelCategoryDbEntity> query,
  315 + string? groupId,
  316 + string? locationId)
  317 + {
  318 + var scopeLocIds = await LocationScopeBindingHelper.ResolveScopedLocationIdsAsync(
  319 + _dbContext.SqlSugarClient, groupId, locationId);
  320 + if (scopeLocIds is null)
  321 + {
  322 + return query;
  323 + }
  324 +
  325 + if (scopeLocIds.Count == 0)
  326 + {
  327 + return query.Where(c => c.AvailabilityType == "ALL");
  328 + }
  329 +
  330 + return query.Where(c =>
  331 + c.AvailabilityType == "ALL" ||
  332 + SqlFunc.Subqueryable<FlLabelCategoryLocationDbEntity>()
  333 + .Where(cl => cl.CategoryId == c.Id && scopeLocIds.Contains(cl.LocationId))
  334 + .Any());
284 } 335 }
285 336
286 - private static List<string> NormalizeLocationIds(List<string>? locationIds) 337 + private const string AllRegionsDisplay = "All Regions";
  338 + private const string AllLocationsDisplay = "All Locations";
  339 + private const string EmptyDisplay = "无";
  340 +
  341 + private async Task<Dictionary<string, CategoryScopeData>> BuildCategoryScopeMapAsync(
  342 + List<FlLabelCategoryDbEntity> entities)
287 { 343 {
288 - return locationIds? 344 + var result = new Dictionary<string, CategoryScopeData>(StringComparer.Ordinal);
  345 + if (entities.Count == 0)
  346 + {
  347 + return result;
  348 + }
  349 +
  350 + foreach (var e in entities.Where(x =>
  351 + !string.Equals(x.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)))
  352 + {
  353 + result[e.Id] = new CategoryScopeData
  354 + {
  355 + Region = AllRegionsDisplay,
  356 + Location = AllLocationsDisplay,
  357 + RegionIds = new List<string>(),
  358 + LocationIds = new List<string>()
  359 + };
  360 + }
  361 +
  362 + var specifiedIds = entities
  363 + .Where(x => string.Equals(x.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase))
  364 + .Select(x => x.Id)
  365 + .ToList();
  366 + if (specifiedIds.Count == 0)
  367 + {
  368 + return result;
  369 + }
  370 +
  371 + var links = await _dbContext.SqlSugarClient.Queryable<FlLabelCategoryLocationDbEntity>()
  372 + .Where(x => specifiedIds.Contains(x.CategoryId))
  373 + .ToListAsync();
  374 +
  375 + var locIdSet = links
  376 + .Select(x => x.LocationId)
289 .Where(x => !string.IsNullOrWhiteSpace(x)) 377 .Where(x => !string.IsNullOrWhiteSpace(x))
290 .Select(x => x.Trim()) 378 .Select(x => x.Trim())
291 - .Distinct()  
292 - .ToList() ?? new(); 379 + .Distinct(StringComparer.Ordinal)
  380 + .ToList();
  381 +
  382 + var locById = new Dictionary<string, LocationAggregateRoot>(StringComparer.Ordinal);
  383 + if (locIdSet.Count > 0)
  384 + {
  385 + var guidList = locIdSet.Where(x => Guid.TryParse(x, out _)).Select(Guid.Parse).ToList();
  386 + if (guidList.Count > 0)
  387 + {
  388 + var locs = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  389 + .Where(x => !x.IsDeleted && guidList.Contains(x.Id))
  390 + .ToListAsync();
  391 + foreach (var loc in locs)
  392 + {
  393 + locById[loc.Id.ToString()] = loc;
  394 + }
  395 + }
  396 + }
  397 +
  398 + foreach (var catId in specifiedIds)
  399 + {
  400 + var catLinks = links.Where(x => x.CategoryId == catId).ToList();
  401 + var locationIds = LocationScopeBindingHelper.NormalizeIds(
  402 + catLinks.Select(x => x.LocationId).ToList());
  403 +
  404 + if (catLinks.Count == 0)
  405 + {
  406 + result[catId] = new CategoryScopeData
  407 + {
  408 + Region = EmptyDisplay,
  409 + Location = EmptyDisplay,
  410 + RegionIds = new List<string>(),
  411 + LocationIds = new List<string>()
  412 + };
  413 + continue;
  414 + }
  415 +
  416 + var regions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  417 + var locationNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  418 + foreach (var link in catLinks)
  419 + {
  420 + var lid = link.LocationId?.Trim() ?? string.Empty;
  421 + if (string.IsNullOrEmpty(lid) || !locById.TryGetValue(lid, out var loc))
  422 + {
  423 + continue;
  424 + }
  425 +
  426 + var groupName = loc.GroupName?.Trim();
  427 + if (!string.IsNullOrEmpty(groupName))
  428 + {
  429 + regions.Add(groupName);
  430 + }
  431 +
  432 + var locName = loc.LocationName?.Trim();
  433 + if (string.IsNullOrEmpty(locName))
  434 + {
  435 + locName = loc.LocationCode?.Trim();
  436 + }
  437 +
  438 + if (!string.IsNullOrEmpty(locName))
  439 + {
  440 + locationNames.Add(locName);
  441 + }
  442 + }
  443 +
  444 + var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
  445 + _dbContext.SqlSugarClient, locationIds);
  446 +
  447 + result[catId] = new CategoryScopeData
  448 + {
  449 + Region = regions.Count > 0
  450 + ? string.Join(", ", regions.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
  451 + : EmptyDisplay,
  452 + Location = locationNames.Count > 0
  453 + ? string.Join(", ", locationNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
  454 + : EmptyDisplay,
  455 + RegionIds = regionIds,
  456 + LocationIds = locationIds
  457 + };
  458 + }
  459 +
  460 + return result;
  461 + }
  462 +
  463 + private sealed class CategoryScopeData
  464 + {
  465 + public string Region { get; init; } = string.Empty;
  466 + public string Location { get; init; } = string.Empty;
  467 + public List<string> RegionIds { get; init; } = new();
  468 + public List<string> LocationIds { get; init; } = new();
  469 + }
  470 +
  471 + private sealed class CategoryLabelStats
  472 + {
  473 + public long Count { get; init; }
  474 + public DateTime MaxEdited { get; init; }
  475 + }
  476 +
  477 + /// <summary>
  478 + /// 统计各分类下标签数量及下属标签最近编辑时间(用于列表 LastEdited 同步)。
  479 + /// </summary>
  480 + private async Task<Dictionary<string, CategoryLabelStats>> BuildCategoryLabelStatsMapAsync(List<string> categoryIds)
  481 + {
  482 + var result = new Dictionary<string, CategoryLabelStats>(StringComparer.Ordinal);
  483 + if (categoryIds.Count == 0)
  484 + {
  485 + return result;
  486 + }
  487 +
  488 + var rows = await _dbContext.SqlSugarClient.Queryable<FlLabelDbEntity>()
  489 + .Where(x => !x.IsDeleted)
  490 + .Where(x => x.LabelCategoryId != null && categoryIds.Contains(x.LabelCategoryId))
  491 + .Select(x => new { x.LabelCategoryId, x.CreationTime, x.LastModificationTime })
  492 + .ToListAsync();
  493 +
  494 + foreach (var g in rows.GroupBy(x => x.LabelCategoryId!))
  495 + {
  496 + result[g.Key] = new CategoryLabelStats
  497 + {
  498 + Count = g.Count(),
  499 + MaxEdited = g.Max(l => l.LastModificationTime ?? l.CreationTime)
  500 + };
  501 + }
  502 +
  503 + return result;
  504 + }
  505 +
  506 + private static DateTime ResolveCategoryCreationTime(FlLabelCategoryDbEntity entity)
  507 + {
  508 + if (entity.CreationTime > DateTime.MinValue.AddYears(1))
  509 + {
  510 + return entity.CreationTime;
  511 + }
  512 +
  513 + return entity.LastModificationTime ?? DateTime.Now;
  514 + }
  515 +
  516 + private static DateTime ResolveLastEdited(
  517 + FlLabelCategoryDbEntity entity,
  518 + DateTime creationTime,
  519 + CategoryLabelStats? labelStats)
  520 + {
  521 + var categoryEdited = entity.LastModificationTime ?? creationTime;
  522 + if (labelStats is null)
  523 + {
  524 + return categoryEdited;
  525 + }
  526 +
  527 + return labelStats.MaxEdited > categoryEdited ? labelStats.MaxEdited : categoryEdited;
  528 + }
  529 +
  530 + /// <summary>
  531 + /// 下属标签变更时回写分类最后编辑时间(与列表 <see cref="LabelCategoryGetListOutputDto.LastEdited"/> 一致)。
  532 + /// </summary>
  533 + internal static async Task TouchLabelCategoryLastEditedAsync(
  534 + ISqlSugarDbContext dbContext,
  535 + string? categoryId,
  536 + string? modifierId)
  537 + {
  538 + var cid = categoryId?.Trim();
  539 + if (string.IsNullOrWhiteSpace(cid))
  540 + {
  541 + return;
  542 + }
  543 +
  544 + await dbContext.SqlSugarClient.Updateable<FlLabelCategoryDbEntity>()
  545 + .SetColumns(x => new FlLabelCategoryDbEntity
  546 + {
  547 + LastModificationTime = DateTime.Now,
  548 + LastModifierId = modifierId
  549 + })
  550 + .Where(x => x.Id == cid && !x.IsDeleted)
  551 + .ExecuteCommandAsync();
293 } 552 }
294 553
295 private async Task SaveCategoryLocationsAsync( 554 private async Task SaveCategoryLocationsAsync(
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs
@@ -101,6 +101,7 @@ public class LocationAppService : ApplicationService, ILocationAppService @@ -101,6 +101,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
101 Email = input.Email?.Trim(), 101 Email = input.Email?.Trim(),
102 Latitude = input.Latitude, 102 Latitude = input.Latitude,
103 Longitude = input.Longitude, 103 Longitude = input.Longitude,
  104 + OperatingHours = input.OperatingHours?.Trim(),
104 State = input.State 105 State = input.State
105 }; 106 };
106 107
@@ -136,6 +137,7 @@ public class LocationAppService : ApplicationService, ILocationAppService @@ -136,6 +137,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
136 entity.Email = input.Email?.Trim(); 137 entity.Email = input.Email?.Trim();
137 entity.Latitude = input.Latitude; 138 entity.Latitude = input.Latitude;
138 entity.Longitude = input.Longitude; 139 entity.Longitude = input.Longitude;
  140 + entity.OperatingHours = input.OperatingHours?.Trim();
139 entity.State = input.State; 141 entity.State = input.State;
140 142
141 await _locationRepository.UpdateAsync(entity); 143 await _locationRepository.UpdateAsync(entity);
@@ -347,7 +349,8 @@ public class LocationAppService : ApplicationService, ILocationAppService @@ -347,7 +349,8 @@ public class LocationAppService : ApplicationService, ILocationAppService
347 (x.Country != null && x.Country.Contains(keyword!)) || 349 (x.Country != null && x.Country.Contains(keyword!)) ||
348 (x.ZipCode != null && x.ZipCode.Contains(keyword!)) || 350 (x.ZipCode != null && x.ZipCode.Contains(keyword!)) ||
349 (x.Phone != null && x.Phone.Contains(keyword!)) || 351 (x.Phone != null && x.Phone.Contains(keyword!)) ||
350 - (x.Email != null && x.Email.Contains(keyword!)) 352 + (x.Email != null && x.Email.Contains(keyword!)) ||
  353 + (x.OperatingHours != null && x.OperatingHours.Contains(keyword!))
351 ); 354 );
352 } 355 }
353 356
@@ -368,6 +371,7 @@ public class LocationAppService : ApplicationService, ILocationAppService @@ -368,6 +371,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
368 Email = x.Email, 371 Email = x.Email,
369 Latitude = x.Latitude, 372 Latitude = x.Latitude,
370 Longitude = x.Longitude, 373 Longitude = x.Longitude,
  374 + OperatingHours = x.OperatingHours,
371 State = x.State 375 State = x.State
372 }; 376 };
373 } 377 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs
@@ -36,7 +36,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService @@ -36,7 +36,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
36 public async Task<PagedResultWithPageDto<PartnerGetListOutputDto>> GetListAsync(PartnerGetListInputVo input) 36 public async Task<PagedResultWithPageDto<PartnerGetListOutputDto>> GetListAsync(PartnerGetListInputVo input)
37 { 37 {
38 RefAsync<int> total = 0; 38 RefAsync<int> total = 0;
39 - var query = BuildPartnerListQuery(input); 39 + var query = await BuildPartnerListQueryAsync(input);
40 40
41 var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total); 41 var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
42 var items = entities.Select(MapListItem).ToList(); 42 var items = entities.Select(MapListItem).ToList();
@@ -44,19 +44,19 @@ public class PartnerAppService : ApplicationService, IPartnerAppService @@ -44,19 +44,19 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
44 } 44 }
45 45
46 /// <inheritdoc /> 46 /// <inheritdoc />
47 - public async Task<PartnerGetOutputDto> GetAsync(string id) 47 + public async Task<PartnerGetOutputDto> GetAsync(Guid id)
48 { 48 {
49 - var partnerId = id?.Trim();  
50 - if (string.IsNullOrWhiteSpace(partnerId)) 49 + if (id == Guid.Empty)
51 { 50 {
52 - throw new UserFriendlyException("合作伙伴Id不能为空"); 51 + throw new UserFriendlyException("Partner id is required.");
53 } 52 }
54 53
  54 + var partnerId = id.ToString();
55 var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>() 55 var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
56 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId); 56 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
57 if (entity is null) 57 if (entity is null)
58 { 58 {
59 - throw new UserFriendlyException("合作伙伴不存在"); 59 + throw new UserFriendlyException("Partner not found.");
60 } 60 }
61 61
62 return MapDetail(entity); 62 return MapDetail(entity);
@@ -69,13 +69,13 @@ public class PartnerAppService : ApplicationService, IPartnerAppService @@ -69,13 +69,13 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
69 var name = input.PartnerName?.Trim(); 69 var name = input.PartnerName?.Trim();
70 if (string.IsNullOrWhiteSpace(name)) 70 if (string.IsNullOrWhiteSpace(name))
71 { 71 {
72 - throw new UserFriendlyException("合作伙伴名称不能为空"); 72 + throw new UserFriendlyException("Partner name is required.");
73 } 73 }
74 74
75 var email = input.ContactEmail?.Trim(); 75 var email = input.ContactEmail?.Trim();
76 if (!string.IsNullOrWhiteSpace(email) && !IsPlausibleEmail(email)) 76 if (!string.IsNullOrWhiteSpace(email) && !IsPlausibleEmail(email))
77 { 77 {
78 - throw new UserFriendlyException("联系邮箱格式不正确"); 78 + throw new UserFriendlyException("Invalid contact email format.");
79 } 79 }
80 80
81 var now = Clock.Now; 81 var now = Clock.Now;
@@ -85,73 +85,75 @@ public class PartnerAppService : ApplicationService, IPartnerAppService @@ -85,73 +85,75 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
85 IsDeleted = false, 85 IsDeleted = false,
86 PartnerName = name, 86 PartnerName = name,
87 ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email, 87 ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email,
88 - PhoneNumber = string.IsNullOrWhiteSpace(input.PhoneNumber) ? null : input.PhoneNumber.Trim(), 88 + PhoneNumber = TrimToNull(input.PhoneNumber),
89 State = input.State, 89 State = input.State,
90 CreationTime = now, 90 CreationTime = now,
91 CreatorId = CurrentUser?.Id?.ToString(), 91 CreatorId = CurrentUser?.Id?.ToString(),
92 LastModificationTime = now, 92 LastModificationTime = now,
93 LastModifierId = CurrentUser?.Id?.ToString() 93 LastModifierId = CurrentUser?.Id?.ToString()
94 }; 94 };
  95 + ApplyAddressFields(entity, input);
95 96
96 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); 97 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
97 - return await GetAsync(entity.Id); 98 + return await GetAsync(Guid.Parse(entity.Id));
98 } 99 }
99 100
100 /// <inheritdoc /> 101 /// <inheritdoc />
101 [UnitOfWork] 102 [UnitOfWork]
102 - public async Task<PartnerGetOutputDto> UpdateAsync(string id, PartnerUpdateInputVo input) 103 + public async Task<PartnerGetOutputDto> UpdateAsync(Guid id, PartnerUpdateInputVo input)
103 { 104 {
104 - var partnerId = id?.Trim();  
105 - if (string.IsNullOrWhiteSpace(partnerId)) 105 + if (id == Guid.Empty)
106 { 106 {
107 - throw new UserFriendlyException("合作伙伴Id不能为空"); 107 + throw new UserFriendlyException("Partner id is required.");
108 } 108 }
109 109
  110 + var partnerId = id.ToString();
110 var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>() 111 var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
111 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId); 112 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
112 if (entity is null) 113 if (entity is null)
113 { 114 {
114 - throw new UserFriendlyException("合作伙伴不存在"); 115 + throw new UserFriendlyException("Partner not found.");
115 } 116 }
116 117
117 var name = input.PartnerName?.Trim(); 118 var name = input.PartnerName?.Trim();
118 if (string.IsNullOrWhiteSpace(name)) 119 if (string.IsNullOrWhiteSpace(name))
119 { 120 {
120 - throw new UserFriendlyException("合作伙伴名称不能为空"); 121 + throw new UserFriendlyException("Partner name is required.");
121 } 122 }
122 123
123 var email = input.ContactEmail?.Trim(); 124 var email = input.ContactEmail?.Trim();
124 if (!string.IsNullOrWhiteSpace(email) && !IsPlausibleEmail(email)) 125 if (!string.IsNullOrWhiteSpace(email) && !IsPlausibleEmail(email))
125 { 126 {
126 - throw new UserFriendlyException("联系邮箱格式不正确"); 127 + throw new UserFriendlyException("Invalid contact email format.");
127 } 128 }
128 129
129 entity.PartnerName = name; 130 entity.PartnerName = name;
130 entity.ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email; 131 entity.ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email;
131 - entity.PhoneNumber = string.IsNullOrWhiteSpace(input.PhoneNumber) ? null : input.PhoneNumber.Trim(); 132 + entity.PhoneNumber = TrimToNull(input.PhoneNumber);
132 entity.State = input.State; 133 entity.State = input.State;
  134 + ApplyAddressFields(entity, input);
133 entity.LastModificationTime = Clock.Now; 135 entity.LastModificationTime = Clock.Now;
134 entity.LastModifierId = CurrentUser?.Id?.ToString(); 136 entity.LastModifierId = CurrentUser?.Id?.ToString();
135 137
136 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); 138 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
137 - return await GetAsync(partnerId); 139 + return await GetAsync(id);
138 } 140 }
139 141
140 /// <inheritdoc /> 142 /// <inheritdoc />
141 [UnitOfWork] 143 [UnitOfWork]
142 - public async Task DeleteAsync(string id) 144 + public async Task DeleteAsync(Guid id)
143 { 145 {
144 - var partnerId = id?.Trim();  
145 - if (string.IsNullOrWhiteSpace(partnerId)) 146 + if (id == Guid.Empty)
146 { 147 {
147 - throw new UserFriendlyException("合作伙伴Id不能为空"); 148 + throw new UserFriendlyException("Partner id is required.");
148 } 149 }
149 150
  151 + var partnerId = id.ToString();
150 var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>() 152 var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
151 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId); 153 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
152 if (entity is null) 154 if (entity is null)
153 { 155 {
154 - throw new UserFriendlyException("合作伙伴不存在"); 156 + throw new UserFriendlyException("Partner not found.");
155 } 157 }
156 158
157 entity.IsDeleted = true; 159 entity.IsDeleted = true;
@@ -161,15 +163,17 @@ public class PartnerAppService : ApplicationService, IPartnerAppService @@ -161,15 +163,17 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
161 } 163 }
162 164
163 /// <inheritdoc /> 165 /// <inheritdoc />
164 - public async Task<IActionResult> ExportPdfAsync(PartnerGetListInputVo input) 166 + [HttpGet]
  167 + public async Task<IActionResult> ExportPdfAsync([FromQuery] PartnerGetListInputVo input)
165 { 168 {
166 QuestPDF.Settings.License = LicenseType.Community; 169 QuestPDF.Settings.License = LicenseType.Community;
167 170
168 - var count = await BuildPartnerListQuery(input).CountAsync();  
169 - var query = BuildPartnerListQuery(input); 171 + var count = await (await BuildPartnerListQueryAsync(input)).CountAsync();
  172 + var query = await BuildPartnerListQueryAsync(input);
170 if (count > ExportPdfMaxRows) 173 if (count > ExportPdfMaxRows)
171 { 174 {
172 - throw new UserFriendlyException($"导出数据超过上限 {ExportPdfMaxRows} 条,请缩小筛选范围"); 175 + throw new UserFriendlyException(
  176 + $"Export exceeds the maximum of {ExportPdfMaxRows} rows. Narrow your filters and try again.");
173 } 177 }
174 178
175 var rows = await query.Take(ExportPdfMaxRows).ToListAsync(); 179 var rows = await query.Take(ExportPdfMaxRows).ToListAsync();
@@ -208,9 +212,9 @@ public class PartnerAppService : ApplicationService, IPartnerAppService @@ -208,9 +212,9 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
208 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5) 212 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
209 .Text(e.PartnerName ?? string.Empty); 213 .Text(e.PartnerName ?? string.Empty);
210 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5) 214 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
211 - .Text(string.IsNullOrWhiteSpace(e.ContactEmail) ? "" : e.ContactEmail!); 215 + .Text(string.IsNullOrWhiteSpace(e.ContactEmail) ? "None" : e.ContactEmail!);
212 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5) 216 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
213 - .Text(string.IsNullOrWhiteSpace(e.PhoneNumber) ? "" : e.PhoneNumber!); 217 + .Text(string.IsNullOrWhiteSpace(e.PhoneNumber) ? "None" : e.PhoneNumber!);
214 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status); 218 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status);
215 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5) 219 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
216 .Text(e.CreationTime.ToString("yyyy-MM-dd HH:mm")); 220 .Text(e.CreationTime.ToString("yyyy-MM-dd HH:mm"));
@@ -225,16 +229,27 @@ public class PartnerAppService : ApplicationService, IPartnerAppService @@ -225,16 +229,27 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
225 return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName }; 229 return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
226 } 230 }
227 231
228 - private ISugarQueryable<FlPartnerDbEntity> BuildPartnerListQuery(PartnerGetListInputVo input) 232 + private async Task<ISugarQueryable<FlPartnerDbEntity>> BuildPartnerListQueryAsync(PartnerGetListInputVo input)
229 { 233 {
  234 + var scope = await PartnerScopeHelper.ResolvePartnerScopeAsync(CurrentUser, _dbContext);
  235 +
230 var keyword = input.Keyword?.Trim(); 236 var keyword = input.Keyword?.Trim();
231 var query = _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>() 237 var query = _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
232 - .Where(x => !x.IsDeleted) 238 + .Where(x => !x.IsDeleted);
  239 +
  240 + query = PartnerScopeHelper.ApplyPartnerScope(query, scope);
  241 +
  242 + query = query
233 .WhereIF(input.State != null, x => x.State == input.State) 243 .WhereIF(input.State != null, x => x.State == input.State)
234 .WhereIF(!string.IsNullOrWhiteSpace(keyword), 244 .WhereIF(!string.IsNullOrWhiteSpace(keyword),
235 x => x.PartnerName.Contains(keyword!) || 245 x => x.PartnerName.Contains(keyword!) ||
236 (x.ContactEmail != null && x.ContactEmail.Contains(keyword!)) || 246 (x.ContactEmail != null && x.ContactEmail.Contains(keyword!)) ||
237 - (x.PhoneNumber != null && x.PhoneNumber.Contains(keyword!))); 247 + (x.PhoneNumber != null && x.PhoneNumber.Contains(keyword!)) ||
  248 + (x.Street != null && x.Street.Contains(keyword!)) ||
  249 + (x.City != null && x.City.Contains(keyword!)) ||
  250 + (x.StateCode != null && x.StateCode.Contains(keyword!)) ||
  251 + (x.Country != null && x.Country.Contains(keyword!)) ||
  252 + (x.ZipCode != null && x.ZipCode.Contains(keyword!)));
238 253
239 if (!string.IsNullOrWhiteSpace(input.Sorting)) 254 if (!string.IsNullOrWhiteSpace(input.Sorting))
240 { 255 {
@@ -276,26 +291,60 @@ public class PartnerAppService : ApplicationService, IPartnerAppService @@ -276,26 +291,60 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
276 return query; 291 return query;
277 } 292 }
278 293
279 - private static PartnerGetListOutputDto MapListItem(FlPartnerDbEntity x) => new() 294 + private static PartnerGetListOutputDto MapListItem(FlPartnerDbEntity x)
280 { 295 {
281 - Id = x.Id,  
282 - PartnerName = x.PartnerName,  
283 - ContactEmail = x.ContactEmail,  
284 - PhoneNumber = x.PhoneNumber,  
285 - State = x.State,  
286 - CreationTime = x.CreationTime  
287 - };  
288 -  
289 - private static PartnerGetOutputDto MapDetail(FlPartnerDbEntity x) => new() 296 + var dto = new PartnerGetListOutputDto
  297 + {
  298 + Id = x.Id,
  299 + PartnerName = x.PartnerName,
  300 + ContactEmail = x.ContactEmail,
  301 + PhoneNumber = x.PhoneNumber,
  302 + State = x.State,
  303 + CreationTime = x.CreationTime
  304 + };
  305 + MapAddressFields(dto, x);
  306 + return dto;
  307 + }
  308 +
  309 + private static PartnerGetOutputDto MapDetail(FlPartnerDbEntity x)
290 { 310 {
291 - Id = x.Id,  
292 - PartnerName = x.PartnerName,  
293 - ContactEmail = x.ContactEmail,  
294 - PhoneNumber = x.PhoneNumber,  
295 - State = x.State,  
296 - CreationTime = x.CreationTime,  
297 - LastModificationTime = x.LastModificationTime  
298 - }; 311 + var dto = new PartnerGetOutputDto
  312 + {
  313 + Id = x.Id,
  314 + PartnerName = x.PartnerName,
  315 + ContactEmail = x.ContactEmail,
  316 + PhoneNumber = x.PhoneNumber,
  317 + State = x.State,
  318 + CreationTime = x.CreationTime,
  319 + LastModificationTime = x.LastModificationTime
  320 + };
  321 + MapAddressFields(dto, x);
  322 + return dto;
  323 + }
  324 +
  325 + private static void ApplyAddressFields(FlPartnerDbEntity entity, PartnerAddressFieldsDto input)
  326 + {
  327 + entity.Street = TrimToNull(input.Street);
  328 + entity.City = TrimToNull(input.City);
  329 + entity.StateCode = TrimToNull(input.StateCode);
  330 + entity.Country = TrimToNull(input.Country);
  331 + entity.ZipCode = TrimToNull(input.ZipCode);
  332 + }
  333 +
  334 + private static void MapAddressFields(PartnerAddressFieldsDto dto, FlPartnerDbEntity entity)
  335 + {
  336 + dto.Street = entity.Street;
  337 + dto.City = entity.City;
  338 + dto.StateCode = entity.StateCode;
  339 + dto.Country = entity.Country;
  340 + dto.ZipCode = entity.ZipCode;
  341 + }
  342 +
  343 + private static string? TrimToNull(string? value)
  344 + {
  345 + var t = value?.Trim();
  346 + return string.IsNullOrEmpty(t) ? null : t;
  347 + }
299 348
300 private static bool IsPlausibleEmail(string email) 349 private static bool IsPlausibleEmail(string email)
301 { 350 {
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
@@ -36,11 +36,12 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -36,11 +36,12 @@ public class ProductAppService : ApplicationService, IProductAppService
36 _batchImportOptions = batchImportOptions; 36 _batchImportOptions = batchImportOptions;
37 } 37 }
38 38
  39 + /// <inheritdoc />
39 public async Task<PagedResultWithPageDto<ProductGetListOutputDto>> GetListAsync(ProductGetListInputVo input) 40 public async Task<PagedResultWithPageDto<ProductGetListOutputDto>> GetListAsync(ProductGetListInputVo input)
40 { 41 {
41 RefAsync<int> total = 0; 42 RefAsync<int> total = 0;
42 43
43 - var query = BuildFilteredProductQuery(input); 44 + var query = await BuildFilteredProductQueryAsync(input);
44 if (!string.IsNullOrWhiteSpace(input.Sorting)) 45 if (!string.IsNullOrWhiteSpace(input.Sorting))
45 { 46 {
46 query = query.OrderBy(input.Sorting); 47 query = query.OrderBy(input.Sorting);
@@ -107,14 +108,14 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -107,14 +108,14 @@ public class ProductAppService : ApplicationService, IProductAppService
107 return BuildPagedResult(input.SkipCount, input.MaxResultCount, (int)total, items); 108 return BuildPagedResult(input.SkipCount, input.MaxResultCount, (int)total, items);
108 } 109 }
109 110
110 - public async Task<ProductGetOutputDto> GetAsync(string id) 111 + public async Task<ProductGetOutputDto> GetAsync(Guid id)
111 { 112 {
112 - var productId = id?.Trim();  
113 - if (string.IsNullOrWhiteSpace(productId)) 113 + if (id == Guid.Empty)
114 { 114 {
115 throw new UserFriendlyException("产品Id不能为空"); 115 throw new UserFriendlyException("产品Id不能为空");
116 } 116 }
117 117
  118 + var productId = id.ToString();
118 var entity = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>() 119 var entity = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
119 .FirstAsync(x => !x.IsDeleted && x.Id == productId); 120 .FirstAsync(x => !x.IsDeleted && x.Id == productId);
120 121
@@ -134,11 +135,15 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -134,11 +135,15 @@ public class ProductAppService : ApplicationService, IProductAppService
134 } 135 }
135 } 136 }
136 137
137 - var locationIds = await _dbContext.SqlSugarClient.Queryable<FlLocationProductDbEntity>()  
138 - .Where(x => x.ProductId == productId)  
139 - .Select(x => x.LocationId)  
140 - .Distinct()  
141 - .ToListAsync(); 138 + var locationIds = LocationScopeBindingHelper.NormalizeIds(
  139 + await _dbContext.SqlSugarClient.Queryable<FlLocationProductDbEntity>()
  140 + .Where(x => x.ProductId == productId)
  141 + .Select(x => x.LocationId)
  142 + .ToListAsync());
  143 + var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
  144 + _dbContext.SqlSugarClient, locationIds);
  145 + var groupIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
  146 + _dbContext.SqlSugarClient, locationIds);
142 147
143 return new ProductGetOutputDto 148 return new ProductGetOutputDto
144 { 149 {
@@ -149,6 +154,9 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -149,6 +154,9 @@ public class ProductAppService : ApplicationService, IProductAppService
149 CategoryName = categoryName, 154 CategoryName = categoryName,
150 ProductImageUrl = entity.ProductImageUrl, 155 ProductImageUrl = entity.ProductImageUrl,
151 State = entity.State, 156 State = entity.State,
  157 + PartnerId = partnerIds.Count > 0 ? partnerIds[0] : null,
  158 + PartnerIds = partnerIds,
  159 + GroupIds = groupIds,
152 LocationIds = locationIds 160 LocationIds = locationIds
153 }; 161 };
154 } 162 }
@@ -190,24 +198,24 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -190,24 +198,24 @@ public class ProductAppService : ApplicationService, IProductAppService
190 198
191 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); 199 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
192 200
193 - if (input.LocationIds is not null) 201 + if (HasProductScopeBinding(input))
194 { 202 {
195 - var locIds = await NormalizeAndValidateLocationIdsAsync(input.LocationIds); 203 + var locIds = await ResolveProductLocationIdsForSaveAsync(input);
196 await ReplaceProductLocationLinksAsync(entity.Id, locIds); 204 await ReplaceProductLocationLinksAsync(entity.Id, locIds);
197 } 205 }
198 206
199 - return await GetAsync(entity.Id); 207 + return await GetAsync(Guid.Parse(entity.Id));
200 } 208 }
201 209
202 [UnitOfWork] 210 [UnitOfWork]
203 - public async Task<ProductGetOutputDto> UpdateAsync(string id, ProductUpdateInputVo input) 211 + public async Task<ProductGetOutputDto> UpdateAsync(Guid id, ProductUpdateInputVo input)
204 { 212 {
205 - var productId = id?.Trim();  
206 - if (string.IsNullOrWhiteSpace(productId)) 213 + if (id == Guid.Empty)
207 { 214 {
208 throw new UserFriendlyException("产品Id不能为空"); 215 throw new UserFriendlyException("产品Id不能为空");
209 } 216 }
210 217
  218 + var productId = id.ToString();
211 var entity = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>() 219 var entity = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
212 .FirstAsync(x => !x.IsDeleted && x.Id == productId); 220 .FirstAsync(x => !x.IsDeleted && x.Id == productId);
213 if (entity is null) 221 if (entity is null)
@@ -246,24 +254,24 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -246,24 +254,24 @@ public class ProductAppService : ApplicationService, IProductAppService
246 254
247 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); 255 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
248 256
249 - if (input.LocationIds is not null) 257 + if (HasProductScopeBinding(input))
250 { 258 {
251 - var locIds = await NormalizeAndValidateLocationIdsAsync(input.LocationIds); 259 + var locIds = await ResolveProductLocationIdsForSaveAsync(input);
252 await ReplaceProductLocationLinksAsync(productId, locIds); 260 await ReplaceProductLocationLinksAsync(productId, locIds);
253 } 261 }
254 262
255 - return await GetAsync(productId); 263 + return await GetAsync(id);
256 } 264 }
257 265
258 [UnitOfWork] 266 [UnitOfWork]
259 - public async Task DeleteAsync(string id) 267 + public async Task DeleteAsync(Guid id)
260 { 268 {
261 - var productId = id?.Trim();  
262 - if (string.IsNullOrWhiteSpace(productId)) 269 + if (id == Guid.Empty)
263 { 270 {
264 throw new UserFriendlyException("产品Id不能为空"); 271 throw new UserFriendlyException("产品Id不能为空");
265 } 272 }
266 273
  274 + var productId = id.ToString();
267 var entity = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>() 275 var entity = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
268 .FirstAsync(x => !x.IsDeleted && x.Id == productId); 276 .FirstAsync(x => !x.IsDeleted && x.Id == productId);
269 if (entity is null) 277 if (entity is null)
@@ -276,6 +284,7 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -276,6 +284,7 @@ public class ProductAppService : ApplicationService, IProductAppService
276 } 284 }
277 285
278 /// <inheritdoc /> 286 /// <inheritdoc />
  287 + [HttpGet]
279 public Task<IActionResult> DownloadProductImportTemplateAsync() 288 public Task<IActionResult> DownloadProductImportTemplateAsync()
280 { 289 {
281 var opt = _batchImportOptions.Value; 290 var opt = _batchImportOptions.Value;
@@ -306,16 +315,20 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -306,16 +315,20 @@ public class ProductAppService : ApplicationService, IProductAppService
306 } 315 }
307 316
308 /// <inheritdoc /> 317 /// <inheritdoc />
  318 + [HttpGet]
309 public async Task<IActionResult> ExportProductsExcelAsync([FromQuery] ProductGetListInputVo input) 319 public async Task<IActionResult> ExportProductsExcelAsync([FromQuery] ProductGetListInputVo input)
310 { 320 {
311 var exportFilter = new ProductGetListInputVo 321 var exportFilter = new ProductGetListInputVo
312 { 322 {
313 Sorting = input.Sorting, 323 Sorting = input.Sorting,
314 Keyword = input.Keyword, 324 Keyword = input.Keyword,
315 - State = input.State 325 + State = input.State,
  326 + PartnerId = input.PartnerId,
  327 + GroupId = input.GroupId,
  328 + LocationId = input.LocationId
316 }; 329 };
317 330
318 - var query = BuildFilteredProductQuery(exportFilter); 331 + var query = await BuildFilteredProductQueryAsync(exportFilter);
319 if (!string.IsNullOrWhiteSpace(exportFilter.Sorting)) 332 if (!string.IsNullOrWhiteSpace(exportFilter.Sorting))
320 { 333 {
321 query = query.OrderBy(exportFilter.Sorting); 334 query = query.OrderBy(exportFilter.Sorting);
@@ -334,6 +347,7 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -334,6 +347,7 @@ public class ProductAppService : ApplicationService, IProductAppService
334 } 347 }
335 348
336 /// <inheritdoc /> 349 /// <inheritdoc />
  350 + [HttpPost]
337 public async Task<ProductBatchImportResultDto> ImportProductsBatchAsync( 351 public async Task<ProductBatchImportResultDto> ImportProductsBatchAsync(
338 [FromForm] ProductBatchImportInputVo input) 352 [FromForm] ProductBatchImportInputVo input)
339 { 353 {
@@ -423,10 +437,14 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -423,10 +437,14 @@ public class ProductAppService : ApplicationService, IProductAppService
423 throw new UserFriendlyException($"单次批量编辑最多允许 {maxItems} 条,请分批提交"); 437 throw new UserFriendlyException($"单次批量编辑最多允许 {maxItems} 条,请分批提交");
424 } 438 }
425 439
426 - var effectiveCount = input.Items.Count(static x => x is not null && !string.IsNullOrWhiteSpace(x.Id)); 440 + var effectiveCount = input.Items.Count(static x =>
  441 + x is not null &&
  442 + !string.IsNullOrWhiteSpace(x.Id) &&
  443 + Guid.TryParse(x.Id!.Trim(), out var g) &&
  444 + g != Guid.Empty);
427 if (effectiveCount == 0) 445 if (effectiveCount == 0)
428 { 446 {
429 - throw new UserFriendlyException("没有有效的产品 Id(请为待保存行填写 id)"); 447 + throw new UserFriendlyException("没有有效的产品 Id(请为待保存行填写 Guid 格式 id)");
430 } 448 }
431 449
432 var result = new ProductBulkUpdateResultDto(); 450 var result = new ProductBulkUpdateResultDto();
@@ -438,9 +456,14 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -438,9 +456,14 @@ public class ProductAppService : ApplicationService, IProductAppService
438 continue; 456 continue;
439 } 457 }
440 458
  459 + if (!Guid.TryParse(item.Id.Trim(), out var productGuid) || productGuid == Guid.Empty)
  460 + {
  461 + continue;
  462 + }
  463 +
441 try 464 try
442 { 465 {
443 - await UpdateAsync(item.Id.Trim(), item); 466 + await UpdateAsync(productGuid, item);
444 result.SuccessCount++; 467 result.SuccessCount++;
445 } 468 }
446 catch (UserFriendlyException ex) 469 catch (UserFriendlyException ex)
@@ -458,7 +481,7 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -458,7 +481,7 @@ public class ProductAppService : ApplicationService, IProductAppService
458 return result; 481 return result;
459 } 482 }
460 483
461 - private ISugarQueryable<FlProductDbEntity> BuildFilteredProductQuery(ProductGetListInputVo input) 484 + private async Task<ISugarQueryable<FlProductDbEntity>> BuildFilteredProductQueryAsync(ProductGetListInputVo input)
462 { 485 {
463 var keyword = input.Keyword?.Trim(); 486 var keyword = input.Keyword?.Trim();
464 487
@@ -467,6 +490,22 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -467,6 +490,22 @@ public class ProductAppService : ApplicationService, IProductAppService
467 .Where(x => !x.IsDeleted) 490 .Where(x => !x.IsDeleted)
468 .WhereIF(input.State != null, x => x.State == input.State); 491 .WhereIF(input.State != null, x => x.State == input.State);
469 492
  493 + var locationIds = await ResolveFilteredLocationIdsAsync(input.PartnerId, input.GroupId, input.LocationId);
  494 + if (locationIds is not null)
  495 + {
  496 + if (locationIds.Count == 0)
  497 + {
  498 + query = query.Where(_ => false);
  499 + }
  500 + else
  501 + {
  502 + query = query.Where(p =>
  503 + SqlFunc.Subqueryable<FlLocationProductDbEntity>()
  504 + .Where(lp => lp.ProductId == p.Id && locationIds.Contains(lp.LocationId))
  505 + .Any());
  506 + }
  507 + }
  508 +
470 if (!string.IsNullOrWhiteSpace(keyword)) 509 if (!string.IsNullOrWhiteSpace(keyword))
471 { 510 {
472 query = query 511 query = query
@@ -481,6 +520,66 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -481,6 +520,66 @@ public class ProductAppService : ApplicationService, IProductAppService
481 return query; 520 return query;
482 } 521 }
483 522
  523 + /// <summary>
  524 + /// 与 Reports 列表一致:按门店 Id 优先,否则按 Region(groupId)或公司(partnerId)解析 location 主键集合。
  525 + /// </summary>
  526 + private async Task<List<string>?> ResolveFilteredLocationIdsAsync(string? partnerId, string? groupId,
  527 + string? locationId)
  528 + {
  529 + var locId = locationId?.Trim();
  530 + if (!string.IsNullOrWhiteSpace(locId))
  531 + {
  532 + if (!Guid.TryParse(locId, out var locationGuid))
  533 + {
  534 + return new List<string>();
  535 + }
  536 +
  537 + var exists = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  538 + .AnyAsync(x => !x.IsDeleted && x.Id == locationGuid);
  539 + return exists ? new List<string> { locId } : new List<string>();
  540 + }
  541 +
  542 + var gid = groupId?.Trim();
  543 + var pid = partnerId?.Trim();
  544 +
  545 + if (string.IsNullOrWhiteSpace(pid) && string.IsNullOrWhiteSpace(gid))
  546 + {
  547 + return null;
  548 + }
  549 +
  550 + var q = _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>().Where(x => !x.IsDeleted);
  551 +
  552 + if (!string.IsNullOrWhiteSpace(gid))
  553 + {
  554 + var g = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
  555 + .FirstAsync(x => !x.IsDeleted && x.Id == gid);
  556 + if (g is null)
  557 + {
  558 + return new List<string>();
  559 + }
  560 +
  561 + var gName = g.GroupName?.Trim() ?? string.Empty;
  562 + var partner = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
  563 + .FirstAsync(x => !x.IsDeleted && x.Id == g.PartnerId);
  564 + var pName = partner?.PartnerName?.Trim() ?? string.Empty;
  565 + q = q.Where(x => x.GroupName == gName && x.Partner == pName);
  566 + }
  567 + else if (!string.IsNullOrWhiteSpace(pid))
  568 + {
  569 + var partner = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
  570 + .FirstAsync(x => !x.IsDeleted && x.Id == pid);
  571 + if (partner is null)
  572 + {
  573 + return new List<string>();
  574 + }
  575 +
  576 + var pName = partner.PartnerName?.Trim() ?? string.Empty;
  577 + q = q.Where(x => x.Partner == pName);
  578 + }
  579 +
  580 + return await q.Select(x => SqlFunc.ToString(x.Id)).ToListAsync();
  581 + }
  582 +
484 private async Task<List<ProductBatchExcelHelper.ExportRow>> BuildProductExcelExportRowsAsync( 583 private async Task<List<ProductBatchExcelHelper.ExportRow>> BuildProductExcelExportRowsAsync(
485 List<FlProductDbEntity> entities) 584 List<FlProductDbEntity> entities)
486 { 585 {
@@ -657,40 +756,23 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -657,40 +756,23 @@ public class ProductAppService : ApplicationService, IProductAppService
657 throw new UserFriendlyException("无法生成唯一产品编码,请稍后重试或手动填写产品编码"); 756 throw new UserFriendlyException("无法生成唯一产品编码,请稍后重试或手动填写产品编码");
658 } 757 }
659 758
  759 + private static bool HasProductScopeBinding(ProductCreateInputVo input) =>
  760 + !string.IsNullOrWhiteSpace(input.PartnerId) ||
  761 + input.GroupIds is not null ||
  762 + input.LocationIds is not null;
  763 +
660 /// <summary> 764 /// <summary>
661 - /// 去重、校验门店 Id 格式与存在性。 765 + /// 合并 Company(partnerId)、Region(groupIds)、门店(locationIds)并校验存在性。
662 /// </summary> 766 /// </summary>
663 - private async Task<List<string>> NormalizeAndValidateLocationIdsAsync(IEnumerable<string> rawIds) 767 + private async Task<List<string>> ResolveProductLocationIdsForSaveAsync(ProductCreateInputVo input)
664 { 768 {
665 - var distinct = rawIds  
666 - .Where(x => !string.IsNullOrWhiteSpace(x))  
667 - .Select(x => x.Trim())  
668 - .Distinct(StringComparer.Ordinal)  
669 - .ToList();  
670 -  
671 - if (distinct.Count == 0)  
672 - {  
673 - return new List<string>();  
674 - }  
675 -  
676 - foreach (var id in distinct)  
677 - {  
678 - if (!Guid.TryParse(id, out _))  
679 - {  
680 - throw new UserFriendlyException("门店Id格式不正确");  
681 - }  
682 - }  
683 -  
684 - var guidList = distinct.Select(Guid.Parse).ToList();  
685 - var existCount = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()  
686 - .Where(x => !x.IsDeleted && guidList.Contains(x.Id))  
687 - .CountAsync();  
688 - if (existCount != distinct.Count)  
689 - {  
690 - throw new UserFriendlyException("门店不存在");  
691 - }  
692 -  
693 - return distinct; 769 + var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
  770 + _dbContext.SqlSugarClient,
  771 + input.PartnerId,
  772 + input.GroupIds,
  773 + input.LocationIds);
  774 + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged);
  775 + return merged;
694 } 776 }
695 777
696 /// <summary> 778 /// <summary>
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs
@@ -3,6 +3,7 @@ using FoodLabeling.Application.Contracts.Dtos.Common; @@ -3,6 +3,7 @@ using FoodLabeling.Application.Contracts.Dtos.Common;
3 using FoodLabeling.Application.Contracts.Dtos.ProductCategory; 3 using FoodLabeling.Application.Contracts.Dtos.ProductCategory;
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 FoodLabeling.Domain.Entities;
6 using SqlSugar; 7 using SqlSugar;
7 using Volo.Abp; 8 using Volo.Abp;
8 using Volo.Abp.Application.Services; 9 using Volo.Abp.Application.Services;
@@ -40,6 +41,8 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp @@ -40,6 +41,8 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
40 (x.DisplayText != null && x.DisplayText.Contains(keyword!))) 41 (x.DisplayText != null && x.DisplayText.Contains(keyword!)))
41 .WhereIF(input.State != null, x => x.State == input.State); 42 .WhereIF(input.State != null, x => x.State == input.State);
42 43
  44 + query = await ApplyCategoryScopeFilterAsync(query, input.GroupId, input.LocationId);
  45 +
43 // Sorting 仅允许白名单字段,避免不同数据库列命名导致 Unknown column 46 // Sorting 仅允许白名单字段,避免不同数据库列命名导致 Unknown column
44 // 同时避免将 input.Sorting 原样拼接到 SQL(存在注入风险) 47 // 同时避免将 input.Sorting 原样拼接到 SQL(存在注入风险)
45 if (!string.IsNullOrWhiteSpace(input.Sorting)) 48 if (!string.IsNullOrWhiteSpace(input.Sorting))
@@ -74,18 +77,28 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp @@ -74,18 +77,28 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
74 77
75 var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total); 78 var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
76 79
77 - var items = entities.Select(x => new ProductCategoryGetListOutputDto 80 + var scopeMap = await BuildCategoryScopeMapAsync(entities);
  81 +
  82 + var items = entities.Select(x =>
78 { 83 {
79 - Id = x.Id,  
80 - CategoryCode = x.CategoryCode,  
81 - CategoryName = x.CategoryName,  
82 - DisplayText = x.DisplayText,  
83 - CategoryPhotoUrl = x.CategoryPhotoUrl,  
84 - ButtonAppearance = x.ButtonAppearance,  
85 - State = x.State,  
86 - AvailabilityType = x.AvailabilityType,  
87 - OrderNum = x.OrderNum,  
88 - LastEdited = x.LastModificationTime ?? x.CreationTime 84 + scopeMap.TryGetValue(x.Id, out var scope);
  85 + return new ProductCategoryGetListOutputDto
  86 + {
  87 + Id = x.Id,
  88 + CategoryCode = x.CategoryCode,
  89 + CategoryName = x.CategoryName,
  90 + DisplayText = x.DisplayText,
  91 + CategoryPhotoUrl = x.CategoryPhotoUrl,
  92 + ButtonAppearance = x.ButtonAppearance,
  93 + State = x.State,
  94 + AvailabilityType = x.AvailabilityType,
  95 + OrderNum = x.OrderNum,
  96 + LastEdited = x.LastModificationTime ?? x.CreationTime,
  97 + Region = scope?.Region ?? string.Empty,
  98 + Location = scope?.Location ?? string.Empty,
  99 + RegionIds = scope?.RegionIds ?? new List<string>(),
  100 + LocationIds = scope?.LocationIds ?? new List<string>()
  101 + };
89 }).ToList(); 102 }).ToList();
90 103
91 return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items); 104 return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
@@ -110,7 +123,11 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp @@ -110,7 +123,11 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
110 .Where(x => x.CategoryId == entity.Id) 123 .Where(x => x.CategoryId == entity.Id)
111 .Select(x => x.LocationId) 124 .Select(x => x.LocationId)
112 .ToListAsync(); 125 .ToListAsync();
113 - dto.LocationIds = locationIds?.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToList() ?? new(); 126 + dto.LocationIds = LocationScopeBindingHelper.NormalizeIds(locationIds);
  127 + var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
  128 + _dbContext.SqlSugarClient, dto.LocationIds);
  129 + dto.RegionIds = regionIds;
  130 + dto.GroupIds = regionIds;
114 } 131 }
115 132
116 return dto; 133 return dto;
@@ -130,9 +147,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp @@ -130,9 +147,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
130 147
131 var displayText = input.DisplayText?.Trim(); 148 var displayText = input.DisplayText?.Trim();
132 var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); 149 var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance);
133 - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant();  
134 - var locationIds = NormalizeLocationIds(input.LocationIds);  
135 - ValidateAvailabilityTypeAndLocations(availabilityType, locationIds); 150 + var (availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input);
136 151
137 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductCategoryDbEntity>() 152 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductCategoryDbEntity>()
138 .AnyAsync(x => !x.IsDeleted && (x.CategoryCode == code || x.CategoryName == name)); 153 .AnyAsync(x => !x.IsDeleted && (x.CategoryCode == code || x.CategoryName == name));
@@ -163,7 +178,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp @@ -163,7 +178,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
163 }; 178 };
164 179
165 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); 180 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
166 - await SaveCategoryLocationsAsync(entity.Id, availabilityType, locationIds, currentUserId, now); 181 + await SaveCategoryLocationsAsync(entity.Id, availabilityType, mergedLocationIds, currentUserId, now);
167 return await GetAsync(entity.Id); 182 return await GetAsync(entity.Id);
168 } 183 }
169 184
@@ -188,9 +203,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp @@ -188,9 +203,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
188 203
189 var displayText = input.DisplayText?.Trim(); 204 var displayText = input.DisplayText?.Trim();
190 var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance); 205 var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance);
191 - var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant();  
192 - var locationIds = NormalizeLocationIds(input.LocationIds);  
193 - ValidateAvailabilityTypeAndLocations(availabilityType, locationIds); 206 + var (availabilityType, mergedLocationIds) = await ResolveCategoryScopeForSaveAsync(input);
194 207
195 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductCategoryDbEntity>() 208 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductCategoryDbEntity>()
196 .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.CategoryCode == code || x.CategoryName == name)); 209 .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.CategoryCode == code || x.CategoryName == name));
@@ -211,7 +224,8 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp @@ -211,7 +224,8 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
211 entity.LastModifierId = CurrentUser?.Id?.ToString(); 224 entity.LastModifierId = CurrentUser?.Id?.ToString();
212 225
213 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); 226 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
214 - await SaveCategoryLocationsAsync(entity.Id, availabilityType, locationIds, entity.LastModifierId, entity.LastModificationTime ?? DateTime.Now); 227 + await SaveCategoryLocationsAsync(entity.Id, availabilityType, mergedLocationIds, entity.LastModifierId,
  228 + entity.LastModificationTime ?? DateTime.Now);
215 return await GetAsync(id); 229 return await GetAsync(id);
216 } 230 }
217 231
@@ -257,26 +271,86 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp @@ -257,26 +271,86 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
257 }; 271 };
258 } 272 }
259 273
260 - private static void ValidateAvailabilityTypeAndLocations(string availabilityType, List<string> locationIds) 274 + private async Task<(string AvailabilityType, List<string> LocationIds)> ResolveCategoryScopeForSaveAsync(
  275 + ProductCategoryCreateInputVo input)
261 { 276 {
  277 + var regionIds = NormalizeRegionIds(input);
  278 + var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds);
  279 + var availabilityType = (input.AvailabilityType ?? "ALL").Trim().ToUpperInvariant();
  280 +
  281 + var hasScopeArrays = input.RegionIds is not null || input.GroupIds is not null || input.LocationIds is not null;
  282 + if (regionIds.Count > 0 || explicitLocationIds.Count > 0)
  283 + {
  284 + availabilityType = "SPECIFIED";
  285 + }
  286 + else if (hasScopeArrays && string.Equals(availabilityType, "ALL", StringComparison.OrdinalIgnoreCase))
  287 + {
  288 + // 显式传了空数组 [] 表示清空指定范围,仍视为 SPECIFIED(0 门店)或改回 ALL
  289 + availabilityType = "ALL";
  290 + }
  291 +
262 if (availabilityType != "ALL" && availabilityType != "SPECIFIED") 292 if (availabilityType != "ALL" && availabilityType != "SPECIFIED")
263 { 293 {
264 throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)"); 294 throw new UserFriendlyException("门店可用范围不合法(ALL/SPECIFIED)");
265 } 295 }
266 296
267 - if (availabilityType == "SPECIFIED" && locationIds.Count == 0) 297 + if (availabilityType == "ALL")
  298 + {
  299 + return ("ALL", new List<string>());
  300 + }
  301 +
  302 + var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
  303 + _dbContext.SqlSugarClient, (IReadOnlyList<string>?)null, regionIds, explicitLocationIds);
  304 + if (merged.Count == 0)
268 { 305 {
269 - throw new UserFriendlyException("指定门店范围时必须至少选择一个门店"); 306 + throw new UserFriendlyException("指定适用区域或门店时,至少需要匹配到一个有效门店");
270 } 307 }
  308 +
  309 + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged);
  310 + return ("SPECIFIED", merged);
271 } 311 }
272 312
273 - private static List<string> NormalizeLocationIds(List<string>? locationIds) 313 + /// <summary>
  314 + /// 合并入参中的 Region 多选(<see cref="ProductCategoryCreateInputVo.RegionIds"/> 与 <see cref="ProductCategoryCreateInputVo.GroupIds"/>)。
  315 + /// </summary>
  316 + private static List<string> NormalizeRegionIds(ProductCategoryCreateInputVo input)
274 { 317 {
275 - return locationIds?  
276 - .Where(x => !string.IsNullOrWhiteSpace(x))  
277 - .Select(x => x.Trim())  
278 - .Distinct()  
279 - .ToList() ?? new(); 318 + var merged = new HashSet<string>(StringComparer.Ordinal);
  319 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.RegionIds))
  320 + {
  321 + merged.Add(id);
  322 + }
  323 +
  324 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.GroupIds))
  325 + {
  326 + merged.Add(id);
  327 + }
  328 +
  329 + return merged.OrderBy(x => x, StringComparer.Ordinal).ToList();
  330 + }
  331 +
  332 + private async Task<ISugarQueryable<FlProductCategoryDbEntity>> ApplyCategoryScopeFilterAsync(
  333 + ISugarQueryable<FlProductCategoryDbEntity> query,
  334 + string? groupId,
  335 + string? locationId)
  336 + {
  337 + var scopeLocIds = await LocationScopeBindingHelper.ResolveScopedLocationIdsAsync(
  338 + _dbContext.SqlSugarClient, groupId, locationId);
  339 + if (scopeLocIds is null)
  340 + {
  341 + return query;
  342 + }
  343 +
  344 + if (scopeLocIds.Count == 0)
  345 + {
  346 + return query.Where(c => c.AvailabilityType == "ALL");
  347 + }
  348 +
  349 + return query.Where(c =>
  350 + c.AvailabilityType == "ALL" ||
  351 + SqlFunc.Subqueryable<FlProductCategoryLocationDbEntity>()
  352 + .Where(cl => cl.CategoryId == c.Id && scopeLocIds.Contains(cl.LocationId))
  353 + .Any());
280 } 354 }
281 355
282 private async Task SaveCategoryLocationsAsync( 356 private async Task SaveCategoryLocationsAsync(
@@ -312,6 +386,143 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp @@ -312,6 +386,143 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
312 await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync(); 386 await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync();
313 } 387 }
314 388
  389 + private const string AllRegionsDisplay = "All Regions";
  390 + private const string AllLocationsDisplay = "All Locations";
  391 + private const string EmptyDisplay = "无";
  392 +
  393 + /// <summary>
  394 + /// 列表行:Region/Location 展示文案 + 多选 Id 数组(编辑回显)。
  395 + /// </summary>
  396 + private async Task<Dictionary<string, CategoryScopeData>> BuildCategoryScopeMapAsync(
  397 + List<FlProductCategoryDbEntity> entities)
  398 + {
  399 + var result = new Dictionary<string, CategoryScopeData>(StringComparer.Ordinal);
  400 + if (entities.Count == 0)
  401 + {
  402 + return result;
  403 + }
  404 +
  405 + foreach (var e in entities.Where(x =>
  406 + !string.Equals(x.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase)))
  407 + {
  408 + result[e.Id] = new CategoryScopeData
  409 + {
  410 + Region = AllRegionsDisplay,
  411 + Location = AllLocationsDisplay,
  412 + RegionIds = new List<string>(),
  413 + LocationIds = new List<string>()
  414 + };
  415 + }
  416 +
  417 + var specifiedIds = entities
  418 + .Where(x => string.Equals(x.AvailabilityType, "SPECIFIED", StringComparison.OrdinalIgnoreCase))
  419 + .Select(x => x.Id)
  420 + .ToList();
  421 + if (specifiedIds.Count == 0)
  422 + {
  423 + return result;
  424 + }
  425 +
  426 + var links = await _dbContext.SqlSugarClient.Queryable<FlProductCategoryLocationDbEntity>()
  427 + .Where(x => specifiedIds.Contains(x.CategoryId))
  428 + .ToListAsync();
  429 +
  430 + var locIdSet = links
  431 + .Select(x => x.LocationId)
  432 + .Where(x => !string.IsNullOrWhiteSpace(x))
  433 + .Select(x => x.Trim())
  434 + .Distinct(StringComparer.Ordinal)
  435 + .ToList();
  436 +
  437 + var locById = new Dictionary<string, LocationAggregateRoot>(StringComparer.Ordinal);
  438 + if (locIdSet.Count > 0)
  439 + {
  440 + var guidList = locIdSet.Where(x => Guid.TryParse(x, out _)).Select(Guid.Parse).ToList();
  441 + if (guidList.Count > 0)
  442 + {
  443 + var locs = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  444 + .Where(x => !x.IsDeleted && guidList.Contains(x.Id))
  445 + .ToListAsync();
  446 + foreach (var loc in locs)
  447 + {
  448 + locById[loc.Id.ToString()] = loc;
  449 + }
  450 + }
  451 + }
  452 +
  453 + foreach (var catId in specifiedIds)
  454 + {
  455 + var catLinks = links.Where(x => x.CategoryId == catId).ToList();
  456 + var locationIds = LocationScopeBindingHelper.NormalizeIds(
  457 + catLinks.Select(x => x.LocationId).ToList());
  458 +
  459 + if (catLinks.Count == 0)
  460 + {
  461 + result[catId] = new CategoryScopeData
  462 + {
  463 + Region = EmptyDisplay,
  464 + Location = EmptyDisplay,
  465 + RegionIds = new List<string>(),
  466 + LocationIds = new List<string>()
  467 + };
  468 + continue;
  469 + }
  470 +
  471 + var regions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  472 + var locationNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  473 + foreach (var link in catLinks)
  474 + {
  475 + var lid = link.LocationId?.Trim() ?? string.Empty;
  476 + if (string.IsNullOrEmpty(lid) || !locById.TryGetValue(lid, out var loc))
  477 + {
  478 + continue;
  479 + }
  480 +
  481 + var groupName = loc.GroupName?.Trim();
  482 + if (!string.IsNullOrEmpty(groupName))
  483 + {
  484 + regions.Add(groupName);
  485 + }
  486 +
  487 + var locName = loc.LocationName?.Trim();
  488 + if (string.IsNullOrEmpty(locName))
  489 + {
  490 + locName = loc.LocationCode?.Trim();
  491 + }
  492 +
  493 + if (!string.IsNullOrEmpty(locName))
  494 + {
  495 + locationNames.Add(locName);
  496 + }
  497 + }
  498 +
  499 + var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
  500 + _dbContext.SqlSugarClient, locationIds);
  501 +
  502 + result[catId] = new CategoryScopeData
  503 + {
  504 + Region = regions.Count > 0
  505 + ? string.Join(", ", regions.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
  506 + : EmptyDisplay,
  507 + Location = locationNames.Count > 0
  508 + ? string.Join(", ", locationNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
  509 + : EmptyDisplay,
  510 + RegionIds = regionIds,
  511 + LocationIds = locationIds
  512 + };
  513 + }
  514 +
  515 + return result;
  516 + }
  517 +
  518 + private sealed class CategoryScopeData
  519 + {
  520 + public string Region { get; init; } = string.Empty;
  521 + public string Location { get; init; } = string.Empty;
  522 + public List<string> RegionIds { get; init; } = new();
  523 + public List<string> LocationIds { get; init; } = new();
  524 + }
  525 +
315 private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total, List<T> items) 526 private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total, List<T> items)
316 { 527 {
317 var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount; 528 var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacRoleAppService.cs
@@ -23,6 +23,7 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService @@ -23,6 +23,7 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
23 { 23 {
24 private readonly ISqlSugarDbContext _dbContext; 24 private readonly ISqlSugarDbContext _dbContext;
25 private readonly ISqlSugarRepository<RoleAggregateRoot, Guid> _roleRepository; 25 private readonly ISqlSugarRepository<RoleAggregateRoot, Guid> _roleRepository;
  26 + private readonly ISqlSugarRepository<MenuAggregateRoot, Guid> _menuRepository;
26 private readonly ISqlSugarRepository<RoleMenuEntity> _roleMenuRepository; 27 private readonly ISqlSugarRepository<RoleMenuEntity> _roleMenuRepository;
27 private readonly ISqlSugarRepository<RoleDeptEntity> _roleDeptRepository; 28 private readonly ISqlSugarRepository<RoleDeptEntity> _roleDeptRepository;
28 private readonly ISqlSugarRepository<UserRoleEntity> _userRoleRepository; 29 private readonly ISqlSugarRepository<UserRoleEntity> _userRoleRepository;
@@ -30,12 +31,14 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService @@ -30,12 +31,14 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
30 public RbacRoleAppService( 31 public RbacRoleAppService(
31 ISqlSugarDbContext dbContext, 32 ISqlSugarDbContext dbContext,
32 ISqlSugarRepository<RoleAggregateRoot, Guid> roleRepository, 33 ISqlSugarRepository<RoleAggregateRoot, Guid> roleRepository,
  34 + ISqlSugarRepository<MenuAggregateRoot, Guid> menuRepository,
33 ISqlSugarRepository<RoleMenuEntity> roleMenuRepository, 35 ISqlSugarRepository<RoleMenuEntity> roleMenuRepository,
34 ISqlSugarRepository<RoleDeptEntity> roleDeptRepository, 36 ISqlSugarRepository<RoleDeptEntity> roleDeptRepository,
35 ISqlSugarRepository<UserRoleEntity> userRoleRepository) 37 ISqlSugarRepository<UserRoleEntity> userRoleRepository)
36 { 38 {
37 _dbContext = dbContext; 39 _dbContext = dbContext;
38 _roleRepository = roleRepository; 40 _roleRepository = roleRepository;
  41 + _menuRepository = menuRepository;
39 _roleMenuRepository = roleMenuRepository; 42 _roleMenuRepository = roleMenuRepository;
40 _roleDeptRepository = roleDeptRepository; 43 _roleDeptRepository = roleDeptRepository;
41 _userRoleRepository = userRoleRepository; 44 _userRoleRepository = userRoleRepository;
@@ -75,6 +78,8 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService @@ -75,6 +78,8 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
75 AccessPermissionCodes = DeserializeAccessPermissionCodes(x.AccessPermissionCodesJson) 78 AccessPermissionCodes = DeserializeAccessPermissionCodes(x.AccessPermissionCodesJson)
76 }).ToList(); 79 }).ToList();
77 80
  81 + await FillAccessPermissionsAsync(items);
  82 +
78 var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount; 83 var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount;
79 var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount); 84 var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
80 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize); 85 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
@@ -103,7 +108,7 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService @@ -103,7 +108,7 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
103 .Select(x => x.MenuId) 108 .Select(x => x.MenuId)
104 .ToListAsync(); 109 .ToListAsync();
105 110
106 - return new RbacRoleGetOutputDto 111 + var dto = new RbacRoleGetOutputDto
107 { 112 {
108 Id = entity.Id, 113 Id = entity.Id,
109 RoleName = entity.RoleName ?? string.Empty, 114 RoleName = entity.RoleName ?? string.Empty,
@@ -115,9 +120,12 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService @@ -115,9 +120,12 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
115 AccessPermissionCodes = DeserializeAccessPermissionCodes(entity.AccessPermissionCodesJson), 120 AccessPermissionCodes = DeserializeAccessPermissionCodes(entity.AccessPermissionCodesJson),
116 MenuIds = menuIds 121 MenuIds = menuIds
117 }; 122 };
  123 + await FillAccessPermissionsAsync(new List<RbacRoleGetListOutputDto> { dto });
  124 + return dto;
118 } 125 }
119 126
120 /// <inheritdoc /> 127 /// <inheritdoc />
  128 + [UnitOfWork]
121 public async Task<RbacRoleGetOutputDto> CreateAsync([FromBody] RbacRoleCreateInputVo input) 129 public async Task<RbacRoleGetOutputDto> CreateAsync([FromBody] RbacRoleCreateInputVo input)
122 { 130 {
123 var roleName = input.RoleName?.Trim(); 131 var roleName = input.RoleName?.Trim();
@@ -145,17 +153,19 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService @@ -145,17 +153,19 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
145 Remark = input.Remark?.Trim(), 153 Remark = input.Remark?.Trim(),
146 DataScope = (Yi.Framework.Rbac.Domain.Shared.Enums.DataScopeEnum)input.DataScope, 154 DataScope = (Yi.Framework.Rbac.Domain.Shared.Enums.DataScopeEnum)input.DataScope,
147 State = input.State, 155 State = input.State,
148 - OrderNum = input.OrderNum ?? 0,  
149 - AccessPermissionCodesJson = SerializeAccessPermissionCodes(ResolveAccessPermissions(input)) 156 + OrderNum = input.OrderNum ?? 0
150 }; 157 };
151 EntityHelper.TrySetId(entity, () => GuidGenerator.Create()); 158 EntityHelper.TrySetId(entity, () => GuidGenerator.Create());
152 159
153 await _roleRepository.InsertAsync(entity); 160 await _roleRepository.InsertAsync(entity);
154 161
  162 + await ApplyRoleMenuBindingsAsync(entity.Id, input);
  163 +
155 return await GetAsync(entity.Id); 164 return await GetAsync(entity.Id);
156 } 165 }
157 166
158 /// <inheritdoc /> 167 /// <inheritdoc />
  168 + [UnitOfWork]
159 public async Task<RbacRoleGetOutputDto> UpdateAsync(Guid id, [FromBody] RbacRoleUpdateInputVo input) 169 public async Task<RbacRoleGetOutputDto> UpdateAsync(Guid id, [FromBody] RbacRoleUpdateInputVo input)
160 { 170 {
161 var entity = await _roleRepository.GetSingleAsync(x => x.Id == id && x.IsDeleted == false); 171 var entity = await _roleRepository.GetSingleAsync(x => x.Id == id && x.IsDeleted == false);
@@ -189,15 +199,157 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService @@ -189,15 +199,157 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
189 entity.Remark = input.Remark?.Trim(); 199 entity.Remark = input.Remark?.Trim();
190 entity.DataScope = (Yi.Framework.Rbac.Domain.Shared.Enums.DataScopeEnum)input.DataScope; 200 entity.DataScope = (Yi.Framework.Rbac.Domain.Shared.Enums.DataScopeEnum)input.DataScope;
191 entity.State = input.State; 201 entity.State = input.State;
192 - entity.OrderNum = input.OrderNum ?? 0;  
193 - entity.AccessPermissionCodesJson =  
194 - SerializeAccessPermissionCodes(ResolveAccessPermissions(input)); 202 + if (input.OrderNum is not null)
  203 + {
  204 + entity.OrderNum = input.OrderNum.Value;
  205 + }
195 206
196 await _roleRepository.UpdateAsync(entity); 207 await _roleRepository.UpdateAsync(entity);
197 208
  209 + await ApplyRoleMenuBindingsAsync(entity.Id, input);
  210 +
198 return await GetAsync(entity.Id); 211 return await GetAsync(entity.Id);
199 } 212 }
200 213
  214 + /// <summary>
  215 + /// 新增/编辑时按 menuIds 或 accessPermissions 绑定角色菜单(二者都未传则不改绑定)
  216 + /// </summary>
  217 + private async Task ApplyRoleMenuBindingsAsync(Guid roleId, RbacRoleCreateInputVo input)
  218 + {
  219 + if (input.MenuIds is not null)
  220 + {
  221 + await SetRoleMenusAsync(roleId, input.MenuIds);
  222 + return;
  223 + }
  224 +
  225 + if (input.AccessPermissions is not null)
  226 + {
  227 + var menuIds = await ResolveMenuIdsFromAccessPermissionsAsync(input.AccessPermissions);
  228 + await SetRoleMenusAsync(roleId, menuIds);
  229 + }
  230 + }
  231 +
  232 + private async Task SetRoleMenusAsync(Guid roleId, List<Guid> menuIds)
  233 + {
  234 + var distinct = menuIds?.Distinct().ToList() ?? new List<Guid>();
  235 + await _roleMenuRepository.DeleteAsync(x => x.RoleId == roleId);
  236 +
  237 + if (distinct.Count == 0)
  238 + {
  239 + return;
  240 + }
  241 +
  242 + var existMenuIds = await _menuRepository._DbQueryable
  243 + .Where(x => x.IsDeleted == false)
  244 + .Where(x => distinct.Contains(x.Id))
  245 + .Select(x => x.Id)
  246 + .ToListAsync();
  247 +
  248 + if (existMenuIds.Count == 0)
  249 + {
  250 + return;
  251 + }
  252 +
  253 + var entities = existMenuIds.Select(menuId => new RoleMenuEntity
  254 + {
  255 + RoleId = roleId,
  256 + MenuId = menuId
  257 + }).ToList();
  258 +
  259 + await _roleMenuRepository.InsertRangeAsync(entities);
  260 + }
  261 +
  262 + private async Task<List<Guid>> ResolveMenuIdsFromAccessPermissionsAsync(string accessPermissions)
  263 + {
  264 + var codes = accessPermissions
  265 + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  266 + .Where(c => !string.IsNullOrWhiteSpace(c))
  267 + .Distinct(StringComparer.Ordinal)
  268 + .ToList();
  269 +
  270 + if (codes.Count == 0)
  271 + {
  272 + return new List<Guid>();
  273 + }
  274 +
  275 + var menus = await _menuRepository._DbQueryable
  276 + .Where(m => m.IsDeleted == false && m.PermissionCode != null)
  277 + .Where(m => codes.Contains(m.PermissionCode!))
  278 + .Select(m => m.Id)
  279 + .ToListAsync();
  280 +
  281 + return menus;
  282 + }
  283 +
  284 + private async Task FillAccessPermissionsAsync(List<RbacRoleGetListOutputDto> items)
  285 + {
  286 + if (items.Count == 0)
  287 + {
  288 + return;
  289 + }
  290 +
  291 + var map = await GetAccessPermissionsByRoleIdsAsync(items.Select(x => x.Id).ToList());
  292 + foreach (var item in items)
  293 + {
  294 + item.AccessPermissions = map.GetValueOrDefault(item.Id, string.Empty);
  295 + }
  296 + }
  297 +
  298 + /// <summary>
  299 + /// 按角色汇总已绑定菜单上的 PermissionCode(去重、英文逗号+空格拼接)
  300 + /// </summary>
  301 + private async Task<Dictionary<Guid, string>> GetAccessPermissionsByRoleIdsAsync(List<Guid> roleIds)
  302 + {
  303 + var result = roleIds.Distinct().ToDictionary(id => id, _ => string.Empty);
  304 + if (result.Count == 0)
  305 + {
  306 + return result;
  307 + }
  308 +
  309 + var distinctRoleIds = result.Keys.ToList();
  310 + var links = await _roleMenuRepository._DbQueryable
  311 + .Where(rm => distinctRoleIds.Contains(rm.RoleId))
  312 + .Select(rm => new { rm.RoleId, rm.MenuId })
  313 + .ToListAsync();
  314 + if (links.Count == 0)
  315 + {
  316 + return result;
  317 + }
  318 +
  319 + var menuIds = links.Select(x => x.MenuId).Distinct().ToList();
  320 + var menus = await _menuRepository._DbQueryable
  321 + .Where(m => menuIds.Contains(m.Id) && m.IsDeleted == false)
  322 + .Select(m => new { m.Id, m.PermissionCode })
  323 + .ToListAsync();
  324 + var permByMenuId = menus.ToDictionary(x => x.Id, x => x.PermissionCode);
  325 +
  326 + var byRole = distinctRoleIds.ToDictionary(id => id, _ => new HashSet<string>(StringComparer.Ordinal));
  327 + foreach (var link in links)
  328 + {
  329 + if (!permByMenuId.TryGetValue(link.MenuId, out var code) || string.IsNullOrWhiteSpace(code))
  330 + {
  331 + continue;
  332 + }
  333 +
  334 + if (byRole.TryGetValue(link.RoleId, out var set))
  335 + {
  336 + set.Add(code.Trim());
  337 + }
  338 + }
  339 +
  340 + foreach (var kv in byRole)
  341 + {
  342 + if (kv.Value.Count == 0)
  343 + {
  344 + continue;
  345 + }
  346 +
  347 + result[kv.Key] = string.Join(", ", kv.Value.OrderBy(x => x, StringComparer.Ordinal));
  348 + }
  349 +
  350 + return result;
  351 + }
  352 +
201 /// <inheritdoc /> 353 /// <inheritdoc />
202 [UnitOfWork] 354 [UnitOfWork]
203 public async Task DeleteAsync([FromBody] List<Guid> ids) 355 public async Task DeleteAsync([FromBody] List<Guid> ids)
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs
@@ -106,6 +106,11 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService @@ -106,6 +106,11 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
106 106
107 var role = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity>().FirstAsync(x => x.UserId == id); 107 var role = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity>().FirstAsync(x => x.UserId == id);
108 108
  109 + var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
  110 + _dbContext.SqlSugarClient, locationIds);
  111 + var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
  112 + _dbContext.SqlSugarClient, locationIds);
  113 +
109 return new TeamMemberGetOutputDto 114 return new TeamMemberGetOutputDto
110 { 115 {
111 Id = user.Id, 116 Id = user.Id,
@@ -115,6 +120,9 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService @@ -115,6 +120,9 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
115 Phone = user.Phone, 120 Phone = user.Phone,
116 State = user.State, 121 State = user.State,
117 RoleId = role?.RoleId, 122 RoleId = role?.RoleId,
  123 + PartnerIds = partnerIds,
  124 + RegionIds = regionIds,
  125 + GroupIds = regionIds,
118 LocationIds = locationIds, 126 LocationIds = locationIds,
119 AssignedLocations = assigned 127 AssignedLocations = assigned
120 }; 128 };
@@ -123,10 +131,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService @@ -123,10 +131,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
123 /// <inheritdoc /> 131 /// <inheritdoc />
124 public async Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input) 132 public async Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input)
125 { 133 {
126 - if (input.LocationIds is null || input.LocationIds.Count == 0)  
127 - {  
128 - throw new UserFriendlyException("成员必须至少分配一个门店");  
129 - } 134 + var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input);
130 135
131 var user = new UserAggregateRoot(input.UserName.Trim(), input.Password, input.Phone, input.FullName.Trim()) 136 var user = new UserAggregateRoot(input.UserName.Trim(), input.Password, input.Phone, input.FullName.Trim())
132 { 137 {
@@ -145,7 +150,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService @@ -145,7 +150,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
145 await _userManager.GiveUserSetRoleAsync(new List<Guid> { user.Id }, new List<Guid> { input.RoleId.Value }); 150 await _userManager.GiveUserSetRoleAsync(new List<Guid> { user.Id }, new List<Guid> { input.RoleId.Value });
146 } 151 }
147 152
148 - await UpsertUserLocationsAsync(user.Id, input.LocationIds); 153 + await UpsertUserLocationsAsync(user.Id, mergedLocationIds);
149 154
150 return await GetAsync(user.Id); 155 return await GetAsync(user.Id);
151 } 156 }
@@ -153,10 +158,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService @@ -153,10 +158,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
153 /// <inheritdoc /> 158 /// <inheritdoc />
154 public async Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input) 159 public async Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input)
155 { 160 {
156 - if (input.LocationIds is null || input.LocationIds.Count == 0)  
157 - {  
158 - throw new UserFriendlyException("成员必须至少分配一个门店");  
159 - } 161 + var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input);
160 162
161 var user = await _userRepository.GetByIdAsync(id); 163 var user = await _userRepository.GetByIdAsync(id);
162 if (user is null || user.IsDeleted) 164 if (user is null || user.IsDeleted)
@@ -187,7 +189,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService @@ -187,7 +189,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
187 await _userManager.GiveUserSetRoleAsync(new List<Guid> { id }, new List<Guid>()); 189 await _userManager.GiveUserSetRoleAsync(new List<Guid> { id }, new List<Guid>());
188 } 190 }
189 191
190 - await UpsertUserLocationsAsync(id, input.LocationIds); 192 + await UpsertUserLocationsAsync(id, mergedLocationIds);
191 193
192 return await GetAsync(id); 194 return await GetAsync(id);
193 } 195 }
@@ -584,10 +586,13 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService @@ -584,10 +586,13 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
584 return null; 586 return null;
585 }).Where(x => x != null).Cast<TeamMemberAssignedLocationDto>().ToList()); 587 }).Where(x => x != null).Cast<TeamMemberAssignedLocationDto>().ToList());
586 588
  589 + var scopeIdsMap = await BuildTeamMemberScopeIdsMapAsync(assignedMap);
  590 +
587 return users.Select(u => 591 return users.Select(u =>
588 { 592 {
589 roleMap.TryGetValue(u.Id, out var role); 593 roleMap.TryGetValue(u.Id, out var role);
590 assignedMap.TryGetValue(u.Id.ToString(), out var assigned); 594 assignedMap.TryGetValue(u.Id.ToString(), out var assigned);
  595 + scopeIdsMap.TryGetValue(u.Id.ToString(), out var scopeIds);
591 596
592 return new TeamMemberGetListOutputDto 597 return new TeamMemberGetListOutputDto
593 { 598 {
@@ -599,11 +604,109 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService @@ -599,11 +604,109 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
599 State = u.State, 604 State = u.State,
600 RoleId = role?.Id, 605 RoleId = role?.Id,
601 RoleName = role?.RoleName, 606 RoleName = role?.RoleName,
  607 + PartnerIds = scopeIds?.PartnerIds ?? new List<string>(),
  608 + RegionIds = scopeIds?.RegionIds ?? new List<string>(),
602 AssignedLocations = assigned ?? new List<TeamMemberAssignedLocationDto>() 609 AssignedLocations = assigned ?? new List<TeamMemberAssignedLocationDto>()
603 }; 610 };
604 }).ToList(); 611 }).ToList();
605 } 612 }
606 613
  614 + private async Task<Dictionary<string, TeamMemberScopeIds>> BuildTeamMemberScopeIdsMapAsync(
  615 + Dictionary<string, List<TeamMemberAssignedLocationDto>> assignedMap)
  616 + {
  617 + var result = new Dictionary<string, TeamMemberScopeIds>(StringComparer.Ordinal);
  618 + foreach (var (userId, assigned) in assignedMap)
  619 + {
  620 + var locationIds = assigned
  621 + .Select(x => x.Id)
  622 + .Where(x => !string.IsNullOrWhiteSpace(x))
  623 + .Select(x => x.Trim())
  624 + .Distinct(StringComparer.Ordinal)
  625 + .ToList();
  626 + if (locationIds.Count == 0)
  627 + {
  628 + result[userId] = new TeamMemberScopeIds();
  629 + continue;
  630 + }
  631 +
  632 + var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
  633 + _dbContext.SqlSugarClient, locationIds);
  634 + var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
  635 + _dbContext.SqlSugarClient, locationIds);
  636 + result[userId] = new TeamMemberScopeIds
  637 + {
  638 + PartnerIds = partnerIds,
  639 + RegionIds = regionIds
  640 + };
  641 + }
  642 +
  643 + return result;
  644 + }
  645 +
  646 + private sealed class TeamMemberScopeIds
  647 + {
  648 + public List<string> PartnerIds { get; init; } = new();
  649 + public List<string> RegionIds { get; init; } = new();
  650 + }
  651 +
  652 + private Task<List<string>> ResolveTeamMemberLocationIdsForSaveAsync(TeamMemberUpdateInputVo input) =>
  653 + ResolveTeamMemberLocationIdsForSaveAsync(new TeamMemberCreateInputVo
  654 + {
  655 + PartnerId = input.PartnerId,
  656 + PartnerIds = input.PartnerIds,
  657 + RegionIds = input.RegionIds,
  658 + GroupIds = input.GroupIds,
  659 + LocationIds = input.LocationIds
  660 + });
  661 +
  662 + private async Task<List<string>> ResolveTeamMemberLocationIdsForSaveAsync(TeamMemberCreateInputVo input)
  663 + {
  664 + var partnerIds = NormalizePartnerIds(input);
  665 + var regionIds = NormalizeRegionIds(input);
  666 + var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
  667 + _dbContext.SqlSugarClient, partnerIds, regionIds, input.LocationIds);
  668 +
  669 + if (merged.Count == 0)
  670 + {
  671 + throw new UserFriendlyException("成员必须至少分配一个门店(公司/区域/门店至少选一项)");
  672 + }
  673 +
  674 + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged);
  675 + return merged;
  676 + }
  677 +
  678 + private static List<string> NormalizePartnerIds(TeamMemberCreateInputVo input)
  679 + {
  680 + var merged = new HashSet<string>(StringComparer.Ordinal);
  681 + if (!string.IsNullOrWhiteSpace(input.PartnerId))
  682 + {
  683 + merged.Add(input.PartnerId.Trim());
  684 + }
  685 +
  686 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.PartnerIds))
  687 + {
  688 + merged.Add(id);
  689 + }
  690 +
  691 + return merged.OrderBy(x => x, StringComparer.Ordinal).ToList();
  692 + }
  693 +
  694 + private static List<string> NormalizeRegionIds(TeamMemberCreateInputVo input)
  695 + {
  696 + var merged = new HashSet<string>(StringComparer.Ordinal);
  697 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.RegionIds))
  698 + {
  699 + merged.Add(id);
  700 + }
  701 +
  702 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.GroupIds))
  703 + {
  704 + merged.Add(id);
  705 + }
  706 +
  707 + return merged.OrderBy(x => x, StringComparer.Ordinal).ToList();
  708 + }
  709 +
607 private async Task UpsertUserLocationsAsync(Guid userId, List<string> locationIds) 710 private async Task UpsertUserLocationsAsync(Guid userId, List<string> locationIds)
608 { 711 {
609 var now = DateTime.Now; 712 var now = DateTime.Now;
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs
@@ -15,6 +15,7 @@ using Lazy.Captcha.Core; @@ -15,6 +15,7 @@ using Lazy.Captcha.Core;
15 using Mapster; 15 using Mapster;
16 using Microsoft.AspNetCore.Authorization; 16 using Microsoft.AspNetCore.Authorization;
17 using Microsoft.AspNetCore.Http; 17 using Microsoft.AspNetCore.Http;
  18 +using Microsoft.AspNetCore.Mvc;
18 using Microsoft.Extensions.Options; 19 using Microsoft.Extensions.Options;
19 using Microsoft.IdentityModel.Tokens; 20 using Microsoft.IdentityModel.Tokens;
20 using SqlSugar; 21 using SqlSugar;
@@ -25,6 +26,8 @@ using Volo.Abp.Security.Claims; @@ -25,6 +26,8 @@ using Volo.Abp.Security.Claims;
25 using Volo.Abp.Uow; 26 using Volo.Abp.Uow;
26 using Volo.Abp.Users; 27 using Volo.Abp.Users;
27 using Yi.Framework.Core.Helper; 28 using Yi.Framework.Core.Helper;
  29 +using Yi.Framework.Rbac.Application.Contracts.Dtos.Account;
  30 +using Yi.Framework.Rbac.Application.Contracts.IServices;
28 using Yi.Framework.Rbac.Domain.Entities; 31 using Yi.Framework.Rbac.Domain.Entities;
29 using Yi.Framework.Rbac.Domain.Managers; 32 using Yi.Framework.Rbac.Domain.Managers;
30 using Yi.Framework.Rbac.Domain.Shared.Consts; 33 using Yi.Framework.Rbac.Domain.Shared.Consts;
@@ -47,6 +50,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService @@ -47,6 +50,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
47 private readonly ICaptcha _captcha; 50 private readonly ICaptcha _captcha;
48 private readonly RbacOptions _rbacOptions; 51 private readonly RbacOptions _rbacOptions;
49 private readonly JwtOptions _jwtOptions; 52 private readonly JwtOptions _jwtOptions;
  53 + private readonly IForgotPasswordByEmailService _forgotPasswordByEmailService;
50 54
51 public UsAppAuthAppService( 55 public UsAppAuthAppService(
52 IAccountManager accountManager, 56 IAccountManager accountManager,
@@ -55,7 +59,8 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService @@ -55,7 +59,8 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
55 IHttpContextAccessor httpContextAccessor, 59 IHttpContextAccessor httpContextAccessor,
56 ICaptcha captcha, 60 ICaptcha captcha,
57 IOptions<JwtOptions> jwtOptions, 61 IOptions<JwtOptions> jwtOptions,
58 - IOptions<RbacOptions> rbacOptions) 62 + IOptions<RbacOptions> rbacOptions,
  63 + IForgotPasswordByEmailService forgotPasswordByEmailService)
59 { 64 {
60 _accountManager = accountManager; 65 _accountManager = accountManager;
61 _userRepository = userRepository; 66 _userRepository = userRepository;
@@ -64,6 +69,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService @@ -64,6 +69,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
64 _captcha = captcha; 69 _captcha = captcha;
65 _jwtOptions = jwtOptions.Value; 70 _jwtOptions = jwtOptions.Value;
66 _rbacOptions = rbacOptions.Value; 71 _rbacOptions = rbacOptions.Value;
  72 + _forgotPasswordByEmailService = forgotPasswordByEmailService;
67 } 73 }
68 74
69 protected ILocalEventBus LocalEventBus => LazyServiceProvider.LazyGetRequiredService<ILocalEventBus>(); 75 protected ILocalEventBus LocalEventBus => LazyServiceProvider.LazyGetRequiredService<ILocalEventBus>();
@@ -124,6 +130,19 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService @@ -124,6 +130,19 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
124 }; 130 };
125 } 131 }
126 132
  133 + /// <inheritdoc />
  134 + [AllowAnonymous]
  135 + [HttpPost("us-app-auth/forgot-password/email/send-code")]
  136 + public virtual Task PostSendForgotPasswordCodeByEmailAsync(EmailCaptchaImageDto input) =>
  137 + _forgotPasswordByEmailService.SendForgotPasswordCodeAsync(input);
  138 +
  139 + /// <inheritdoc />
  140 + [AllowAnonymous]
  141 + [UnitOfWork]
  142 + [HttpPost("us-app-auth/forgot-password/email/reset")]
  143 + public virtual Task<string> PostResetPasswordByEmailAsync(RetrievePasswordByEmailDto input) =>
  144 + _forgotPasswordByEmailService.ResetPasswordByEmailAsync(input);
  145 +
127 /// <summary> 146 /// <summary>
128 /// 获取当前登录用户已绑定的门店(切换门店时可重新拉取) 147 /// 获取当前登录用户已绑定的门店(切换门店时可重新拉取)
129 /// </summary> 148 /// </summary>
@@ -147,7 +166,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService @@ -147,7 +166,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
147 /// 店长:在同店绑定用户中,取 <c>Role.RoleCode</c> 或 <c>Role.RoleName</c>(忽略大小写)包含 <c>manager</c> 的第一条; 166 /// 店长:在同店绑定用户中,取 <c>Role.RoleCode</c> 或 <c>Role.RoleName</c>(忽略大小写)包含 <c>manager</c> 的第一条;
148 /// 若无匹配则店长姓名与电话均为「无」。 167 /// 若无匹配则店长姓名与电话均为「无」。
149 /// 168 ///
150 - /// <c>OperatingHours</c>:当前 <c>location</c> 表无营业时间字段,固定返回「无」。 169 + /// <c>OperatingHours</c>:读取 <c>location.OperatingHours</c>;为空时返回「无」。
151 /// </remarks> 170 /// </remarks>
152 /// <param name="locationId">门店主键(Guid 字符串)</param> 171 /// <param name="locationId">门店主键(Guid 字符串)</param>
153 /// <returns>与原型一致的展示字段</returns> 172 /// <returns>与原型一致的展示字段</returns>
@@ -193,7 +212,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService @@ -193,7 +212,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
193 LocationName = string.IsNullOrWhiteSpace(loc.LocationName) ? "无" : loc.LocationName.Trim(), 212 LocationName = string.IsNullOrWhiteSpace(loc.LocationName) ? "无" : loc.LocationName.Trim(),
194 FullAddress = BuildFullAddress(loc), 213 FullAddress = BuildFullAddress(loc),
195 StorePhone = FormatStorePhoneDisplay(loc.Phone), 214 StorePhone = FormatStorePhoneDisplay(loc.Phone),
196 - OperatingHours = "无", 215 + OperatingHours = string.IsNullOrWhiteSpace(loc.OperatingHours) ? "无" : loc.OperatingHours.Trim(),
197 ManagerName = mgrName, 216 ManagerName = mgrName,
198 ManagerPhone = mgrPhone 217 ManagerPhone = mgrPhone
199 }; 218 };
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Domain/Entities/LocationAggregateRoot.cs
@@ -130,5 +130,11 @@ public class LocationAggregateRoot : AggregateRoot&lt;Guid&gt;, ISoftDelete, IAuditedO @@ -130,5 +130,11 @@ public class LocationAggregateRoot : AggregateRoot&lt;Guid&gt;, ISoftDelete, IAuditedO
130 /// Longitude 130 /// Longitude
131 /// </summary> 131 /// </summary>
132 public decimal? Longitude { get; set; } 132 public decimal? Longitude { get; set; }
  133 +
  134 + /// <summary>
  135 + /// 经营时间(自由文本,如 Mon–Fri 9:00 AM – 6:00 PM)
  136 + /// </summary>
  137 + [SugarColumn(ColumnName = "OperatingHours")]
  138 + public string? OperatingHours { get; set; }
133 } 139 }
134 140
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql
@@ -11,6 +11,11 @@ CREATE TABLE IF NOT EXISTS `fl_partner` ( @@ -11,6 +11,11 @@ CREATE TABLE IF NOT EXISTS `fl_partner` (
11 `PartnerName` varchar(256) NOT NULL COMMENT '合作伙伴名称', 11 `PartnerName` varchar(256) NOT NULL COMMENT '合作伙伴名称',
12 `ContactEmail` varchar(256) DEFAULT NULL COMMENT '联系邮箱', 12 `ContactEmail` varchar(256) DEFAULT NULL COMMENT '联系邮箱',
13 `PhoneNumber` varchar(64) DEFAULT NULL COMMENT '电话', 13 `PhoneNumber` varchar(64) DEFAULT NULL COMMENT '电话',
  14 + `Street` varchar(512) DEFAULT NULL COMMENT '街道地址',
  15 + `City` varchar(128) DEFAULT NULL COMMENT '城市',
  16 + `StateCode` varchar(32) DEFAULT NULL COMMENT '州/省代码(如 NY)',
  17 + `Country` varchar(128) DEFAULT NULL COMMENT '国家',
  18 + `ZipCode` varchar(32) DEFAULT NULL COMMENT '邮编',
14 `State` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用', 19 `State` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
15 PRIMARY KEY (`Id`), 20 PRIMARY KEY (`Id`),
16 KEY `IX_fl_partner_IsDeleted` (`IsDeleted`), 21 KEY `IX_fl_partner_IsDeleted` (`IsDeleted`),
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application.Contracts/Dtos/Role/RoleGetListOutputDto.cs
@@ -14,5 +14,10 @@ namespace Yi.Framework.Rbac.Application.Contracts.Dtos.Role @@ -14,5 +14,10 @@ namespace Yi.Framework.Rbac.Application.Contracts.Dtos.Role
14 public bool State { get; set; } 14 public bool State { get; set; }
15 15
16 public int OrderNum { get; set; } 16 public int OrderNum { get; set; }
  17 +
  18 + /// <summary>
  19 + /// 访问权限:该角色在已绑定菜单上的 PermissionCode 汇总(去重、逗号+空格拼接;无则为空串)
  20 + /// </summary>
  21 + public string AccessPermissions { get; set; } = string.Empty;
17 } 22 }
18 } 23 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application.Contracts/Dtos/Role/RoleGetOutputDto.cs
@@ -14,5 +14,10 @@ namespace Yi.Framework.Rbac.Application.Contracts.Dtos.Role @@ -14,5 +14,10 @@ namespace Yi.Framework.Rbac.Application.Contracts.Dtos.Role
14 public bool State { get; set; } 14 public bool State { get; set; }
15 15
16 public int OrderNum { get; set; } 16 public int OrderNum { get; set; }
  17 +
  18 + /// <summary>
  19 + /// 访问权限:该角色在已绑定菜单上的 PermissionCode 汇总(去重、逗号+空格拼接;无则为空串)
  20 + /// </summary>
  21 + public string AccessPermissions { get; set; } = string.Empty;
17 } 22 }
18 } 23 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application/Services/AccountService.cs
@@ -37,6 +37,7 @@ namespace Yi.Framework.Rbac.Application.Services @@ -37,6 +37,7 @@ namespace Yi.Framework.Rbac.Application.Services
37 private readonly IGuidGenerator _guidGenerator; 37 private readonly IGuidGenerator _guidGenerator;
38 private readonly RbacOptions _rbacOptions; 38 private readonly RbacOptions _rbacOptions;
39 private readonly IAliyunManger _aliyunManger; 39 private readonly IAliyunManger _aliyunManger;
  40 + private readonly IForgotPasswordByEmailService _forgotPasswordByEmailService;
40 private IDistributedCache<UserInfoCacheItem, UserInfoCacheKey> _userCache; 41 private IDistributedCache<UserInfoCacheItem, UserInfoCacheKey> _userCache;
41 private UserManager _userManager; 42 private UserManager _userManager;
42 private IHttpContextAccessor _httpContextAccessor; 43 private IHttpContextAccessor _httpContextAccessor;
@@ -51,6 +52,7 @@ namespace Yi.Framework.Rbac.Application.Services @@ -51,6 +52,7 @@ namespace Yi.Framework.Rbac.Application.Services
51 IGuidGenerator guidGenerator, 52 IGuidGenerator guidGenerator,
52 IOptions<RbacOptions> options, 53 IOptions<RbacOptions> options,
53 IAliyunManger aliyunManger, 54 IAliyunManger aliyunManger,
  55 + IForgotPasswordByEmailService forgotPasswordByEmailService,
54 UserManager userManager, IHttpContextAccessor httpContextAccessor) 56 UserManager userManager, IHttpContextAccessor httpContextAccessor)
55 { 57 {
56 _userRepository = userRepository; 58 _userRepository = userRepository;
@@ -62,6 +64,7 @@ namespace Yi.Framework.Rbac.Application.Services @@ -62,6 +64,7 @@ namespace Yi.Framework.Rbac.Application.Services
62 _guidGenerator = guidGenerator; 64 _guidGenerator = guidGenerator;
63 _rbacOptions = options.Value; 65 _rbacOptions = options.Value;
64 _aliyunManger = aliyunManger; 66 _aliyunManger = aliyunManger;
  67 + _forgotPasswordByEmailService = forgotPasswordByEmailService;
65 _userCache = userCache; 68 _userCache = userCache;
66 _userManager = userManager; 69 _userManager = userManager;
67 _httpContextAccessor = httpContextAccessor; 70 _httpContextAccessor = httpContextAccessor;
@@ -328,6 +331,58 @@ namespace Yi.Framework.Rbac.Application.Services @@ -328,6 +331,58 @@ namespace Yi.Framework.Rbac.Application.Services
328 return entity.UserName; 331 return entity.UserName;
329 } 332 }
330 333
  334 + /// <summary>
  335 + /// Send a forgot-password verification code to the account email (platform).
  336 + /// </summary>
  337 + /// <remarks>
  338 + /// When <c>RbacOptions.EnableCaptcha</c> is true, pass <c>Uuid</c> and <c>Code</c> from <c>GetCaptchaImageAsync</c>.
  339 + /// If the email is not registered, the API still returns 200 without sending mail (anti-enumeration).
  340 + ///
  341 + /// Example request:
  342 + /// ```json
  343 + /// {
  344 + /// "email": "user@example.com",
  345 + /// "uuid": "optional-captcha-uuid",
  346 + /// "code": "optional-captcha-text"
  347 + /// }
  348 + /// ```
  349 + /// </remarks>
  350 + /// <param name="input">Email and optional image captcha fields.</param>
  351 + /// <returns>No payload when accepted.</returns>
  352 + /// <response code="200">Accepted (email may or may not exist).</response>
  353 + /// <response code="400">Invalid input, captcha, rate limit, or SMTP not configured.</response>
  354 + /// <response code="500">Server error</response>
  355 + [AllowAnonymous]
  356 + [HttpPost("account/forgot-password/email/send-code")]
  357 + public virtual Task PostSendForgotPasswordCodeByEmailAsync(EmailCaptchaImageDto input) =>
  358 + _forgotPasswordByEmailService.SendForgotPasswordCodeAsync(input);
  359 +
  360 + /// <summary>
  361 + /// Reset password using the email verification code (platform).
  362 + /// </summary>
  363 + /// <remarks>
  364 + /// New password rules: at least 8 characters; upper and lower case; digit; special character.
  365 + ///
  366 + /// Example request:
  367 + /// ```json
  368 + /// {
  369 + /// "email": "user@example.com",
  370 + /// "code": "ABCDEF",
  371 + /// "password": "NewPassw0rd!"
  372 + /// }
  373 + /// ```
  374 + /// </remarks>
  375 + /// <param name="input">Email, OTP, and new password.</param>
  376 + /// <returns>Matched account user name.</returns>
  377 + /// <response code="200">Password updated.</response>
  378 + /// <response code="400">Invalid OTP, weak password, or account not found.</response>
  379 + /// <response code="500">Server error</response>
  380 + [AllowAnonymous]
  381 + [UnitOfWork]
  382 + [HttpPost("account/forgot-password/email/reset")]
  383 + public virtual Task<string> PostResetPasswordByEmailAsync(RetrievePasswordByEmailDto input) =>
  384 + _forgotPasswordByEmailService.ResetPasswordByEmailAsync(input);
  385 +
331 386
332 /// <summary> 387 /// <summary>
333 /// 注册,需要验证码通过 388 /// 注册,需要验证码通过
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application/Services/System/RoleService.cs
@@ -19,6 +19,7 @@ using Yi.Framework.Rbac.Domain.Managers; @@ -19,6 +19,7 @@ using Yi.Framework.Rbac.Domain.Managers;
19 using Yi.Framework.Rbac.Domain.Shared.Consts; 19 using Yi.Framework.Rbac.Domain.Shared.Consts;
20 using Yi.Framework.Rbac.Domain.Shared.Enums; 20 using Yi.Framework.Rbac.Domain.Shared.Enums;
21 using Yi.Framework.SqlSugarCore.Abstractions; 21 using Yi.Framework.SqlSugarCore.Abstractions;
  22 +using System.Linq;
22 23
23 namespace Yi.Framework.Rbac.Application.Services.System 24 namespace Yi.Framework.Rbac.Application.Services.System
24 { 25 {
@@ -75,7 +76,17 @@ namespace Yi.Framework.Rbac.Application.Services.System @@ -75,7 +76,17 @@ namespace Yi.Framework.Rbac.Application.Services.System
75 .WhereIF(!string.IsNullOrEmpty(input.RoleName), x => x.RoleName.Contains(input.RoleName!)) 76 .WhereIF(!string.IsNullOrEmpty(input.RoleName), x => x.RoleName.Contains(input.RoleName!))
76 .WhereIF(input.State is not null, x => x.State == input.State) 77 .WhereIF(input.State is not null, x => x.State == input.State)
77 .ToPageListAsync(input.SkipCount, input.MaxResultCount, total); 78 .ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
78 - return new PagedResultDto<RoleGetListOutputDto>(total, await MapToGetListOutputDtosAsync(entities)); 79 + var list = await MapToGetListOutputDtosAsync(entities);
  80 + await FillAccessPermissionsForRoleListAsync(list);
  81 + return new PagedResultDto<RoleGetListOutputDto>(total, list);
  82 + }
  83 +
  84 + /// <inheritdoc />
  85 + public override async Task<RoleGetOutputDto> GetAsync(Guid id)
  86 + {
  87 + var dto = await base.GetAsync(id);
  88 + await FillAccessPermissionsForRoleGetAsync(dto);
  89 + return dto;
79 } 90 }
80 91
81 /// <inheritdoc /> 92 /// <inheritdoc />
@@ -165,9 +176,7 @@ namespace Yi.Framework.Rbac.Application.Services.System @@ -165,9 +176,7 @@ namespace Yi.Framework.Rbac.Application.Services.System
165 176
166 var entity = await MapToEntityAsync(input); 177 var entity = await MapToEntityAsync(input);
167 await _repository.InsertAsync(entity); 178 await _repository.InsertAsync(entity);
168 - var outputDto = await MapToGetOutputDtoAsync(entity);  
169 -  
170 - return outputDto; 179 + return await MapToGetOutputDtoWithPermissionsAsync(entity);
171 } 180 }
172 181
173 /// <summary> 182 /// <summary>
@@ -191,8 +200,7 @@ namespace Yi.Framework.Rbac.Application.Services.System @@ -191,8 +200,7 @@ namespace Yi.Framework.Rbac.Application.Services.System
191 200
192 await _roleManager.GiveRoleSetMenuAsync(new List<Guid> { id }, input.MenuIds); 201 await _roleManager.GiveRoleSetMenuAsync(new List<Guid> { id }, input.MenuIds);
193 202
194 - var dto = await MapToGetOutputDtoAsync(entity);  
195 - return dto; 203 + return await MapToGetOutputDtoWithPermissionsAsync(entity);
196 } 204 }
197 205
198 206
@@ -213,7 +221,7 @@ namespace Yi.Framework.Rbac.Application.Services.System @@ -213,7 +221,7 @@ namespace Yi.Framework.Rbac.Application.Services.System
213 221
214 entity.State = state; 222 entity.State = state;
215 await _repository.UpdateAsync(entity); 223 await _repository.UpdateAsync(entity);
216 - return await MapToGetOutputDtoAsync(entity); 224 + return await MapToGetOutputDtoWithPermissionsAsync(entity);
217 } 225 }
218 226
219 227
@@ -337,5 +345,92 @@ namespace Yi.Framework.Rbac.Application.Services.System @@ -337,5 +345,92 @@ namespace Yi.Framework.Rbac.Application.Services.System
337 depts = deptList.DeptTreeBuild() 345 depts = deptList.DeptTreeBuild()
338 }); 346 });
339 } 347 }
  348 +
  349 + private async Task<RoleGetOutputDto> MapToGetOutputDtoWithPermissionsAsync(RoleAggregateRoot entity)
  350 + {
  351 + var dto = await MapToGetOutputDtoAsync(entity);
  352 + await FillAccessPermissionsForRoleGetAsync(dto);
  353 + return dto;
  354 + }
  355 +
  356 + private async Task FillAccessPermissionsForRoleListAsync(List<RoleGetListOutputDto> items)
  357 + {
  358 + if (items.Count == 0)
  359 + {
  360 + return;
  361 + }
  362 +
  363 + var map = await GetAccessPermissionsByRoleIdsAsync(items.Select(x => x.Id).ToList());
  364 + foreach (var item in items)
  365 + {
  366 + item.AccessPermissions = map.GetValueOrDefault(item.Id, string.Empty);
  367 + }
  368 + }
  369 +
  370 + private async Task FillAccessPermissionsForRoleGetAsync(RoleGetOutputDto dto)
  371 + {
  372 + var map = await GetAccessPermissionsByRoleIdsAsync(new List<Guid> { dto.Id });
  373 + dto.AccessPermissions = map.GetValueOrDefault(dto.Id, string.Empty);
  374 + }
  375 +
  376 + /// <summary>
  377 + /// 按角色汇总已绑定菜单上的 PermissionCode(去重、英文逗号+空格拼接)
  378 + /// </summary>
  379 + private async Task<Dictionary<Guid, string>> GetAccessPermissionsByRoleIdsAsync(List<Guid> roleIds)
  380 + {
  381 + var result = roleIds.Distinct().ToDictionary(id => id, _ => string.Empty);
  382 + if (result.Count == 0)
  383 + {
  384 + return result;
  385 + }
  386 +
  387 + var distinctRoleIds = result.Keys.ToList();
  388 + var links = await _menuRepository._Db.Queryable<RoleMenuEntity>()
  389 + .Where(rm => distinctRoleIds.Contains(rm.RoleId))
  390 + .Select(rm => new { rm.RoleId, rm.MenuId })
  391 + .ToListAsync();
  392 + if (links.Count == 0)
  393 + {
  394 + return result;
  395 + }
  396 +
  397 + var menuIds = links.Select(x => x.MenuId).Distinct().ToList();
  398 + var menus = await _menuRepository._DbQueryable
  399 + .Where(m => menuIds.Contains(m.Id) && m.IsDeleted == false)
  400 + .Select(m => new { m.Id, m.PermissionCode })
  401 + .ToListAsync();
  402 + var permByMenuId = menus.ToDictionary(x => x.Id, x => x.PermissionCode);
  403 +
  404 + var byRole = new Dictionary<Guid, HashSet<string>>();
  405 + foreach (var rid in distinctRoleIds)
  406 + {
  407 + byRole[rid] = new HashSet<string>(StringComparer.Ordinal);
  408 + }
  409 +
  410 + foreach (var link in links)
  411 + {
  412 + if (!permByMenuId.TryGetValue(link.MenuId, out var code) || string.IsNullOrWhiteSpace(code))
  413 + {
  414 + continue;
  415 + }
  416 +
  417 + if (byRole.TryGetValue(link.RoleId, out var set))
  418 + {
  419 + set.Add(code.Trim());
  420 + }
  421 + }
  422 +
  423 + foreach (var kv in byRole)
  424 + {
  425 + if (kv.Value.Count == 0)
  426 + {
  427 + continue;
  428 + }
  429 +
  430 + result[kv.Key] = string.Join(", ", kv.Value.OrderBy(x => x, StringComparer.Ordinal));
  431 + }
  432 +
  433 + return result;
  434 + }
340 } 435 }
341 } 436 }
342 \ No newline at end of file 437 \ No newline at end of file
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application/Yi.Framework.Rbac.Application.csproj
@@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
8 8
9 9
10 <ItemGroup> 10 <ItemGroup>
  11 + <PackageReference Include="MailKit" Version="4.8.0" />
11 <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" /> 12 <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" />
12 <PackageReference Include="QuestPDF" Version="2024.12.2" /> 13 <PackageReference Include="QuestPDF" Version="2024.12.2" />
13 <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.7" /> 14 <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.7" />
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain.Shared/YiFrameworkRbacDomainSharedModule.cs
@@ -18,6 +18,8 @@ namespace Yi.Framework.Rbac.Domain.Shared @@ -18,6 +18,8 @@ namespace Yi.Framework.Rbac.Domain.Shared
18 Configure<JwtOptions>(configuration.GetSection(nameof(JwtOptions))); 18 Configure<JwtOptions>(configuration.GetSection(nameof(JwtOptions)));
19 Configure<RefreshJwtOptions>(configuration.GetSection(nameof(RefreshJwtOptions))); 19 Configure<RefreshJwtOptions>(configuration.GetSection(nameof(RefreshJwtOptions)));
20 Configure<RbacOptions>(configuration.GetSection(nameof(RbacOptions))); 20 Configure<RbacOptions>(configuration.GetSection(nameof(RbacOptions)));
  21 + Configure<ForgotPasswordEmailOptions>(
  22 + configuration.GetSection(ForgotPasswordEmailOptions.SectionName));
21 } 23 }
22 } 24 }
23 } 25 }
24 \ No newline at end of file 26 \ No newline at end of file
美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/Yi.Abp.Web.csproj
@@ -8,6 +8,8 @@ @@ -8,6 +8,8 @@
8 8
9 9
10 <ItemGroup> 10 <ItemGroup>
  11 + <!-- 与 FoodLabeling.Application 中 Excel 导出一致;显式引用避免部分发布/拷贝场景下漏带 ClosedXML.dll -->
  12 + <PackageReference Include="ClosedXML" Version="0.102.3" />
11 <PackageReference Include="Hangfire.MemoryStorage" Version="1.8.1.1" /> 13 <PackageReference Include="Hangfire.MemoryStorage" Version="1.8.1.1" />
12 <PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.4" /> 14 <PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.4" />
13 <!-- 解决 docker 构建冲突问题,冲突版本信息如下 --> 15 <!-- 解决 docker 构建冲突问题,冲突版本信息如下 -->
@@ -67,4 +69,11 @@ @@ -67,4 +69,11 @@
67 </None> 69 </None>
68 </ItemGroup> 70 </ItemGroup>
69 71
  72 + <!-- 避免「只拷业务 dll 覆盖线上」导致缺 ClosedXML;完整 dotnet publish 应始终包含该文件 -->
  73 + <Target Name="VerifyClosedXmlInPublishOutput" AfterTargets="Publish">
  74 + <Error
  75 + Condition="!Exists('$(PublishDir)ClosedXML.dll')"
  76 + Text="发布目录缺少 ClosedXML.dll。请使用 dotnet publish Yi.Abp.Web 的完整输出(含全部依赖 dll 与 Yi.Abp.Web.deps.json)部署到服务器,勿仅用 bin 内部分文件做覆盖。" />
  77 + </Target>
  78 +
70 </Project> 79 </Project>
美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json
@@ -18,10 +18,10 @@ @@ -18,10 +18,10 @@
18 "Microsoft.AspNetCore": "Warning" 18 "Microsoft.AspNetCore": "Warning"
19 } 19 }
20 }, 20 },
21 - //应用启动 21 + //应用启动:SelfUrl 供 Program.cs UseUrls 绑定;用 0.0.0.0 避免写成固定局域网 IP 在本机无该网卡时启动失败(WinError 10049)
22 "App": { 22 "App": {
23 "SelfUrl": "http://localhost:19001", 23 "SelfUrl": "http://localhost:19001",
24 - "CorsOrigins": "http://localhost:19001;http://localhost:18000;http://localhost:5666" 24 + "CorsOrigins": "http://localhost:19001;http://localhost:18000;http://localhost:5666;http://localhost:3000"
25 }, 25 },
26 //配置 26 //配置
27 "Settings": { 27 "Settings": {
@@ -104,11 +104,39 @@ @@ -104,11 +104,39 @@
104 //开启定时数据库备份 104 //开启定时数据库备份
105 "EnableDataBaseBackup": false 105 "EnableDataBaseBackup": false
106 }, 106 },
  107 +
  108 + // Forgot password: Microsoft 365 may return 535 until SMTP AUTH is enabled for the mailbox
  109 + // (Exchange admin) and credentials are correct; use an app password if MFA is on.
  110 + "ForgotPasswordEmail": {
  111 + "IsEnabled": true,
  112 + "SmtpHost": "smtp.office365.com",
  113 + "SmtpPort": 587,
  114 + "SecureSocketPreset": "StartTls",
  115 + "UserName": "Sandi.ma@3ffoodsafety.com",
  116 + "Password": "",
  117 + "FromAddress": "Sandi.ma@3ffoodsafety.com",
  118 + "FromDisplayName": "Food Labeling",
  119 + "CodeLength": 6,
  120 + "CacheExpirationMinutes": 10
  121 + },
107 122
108 //语义内核 123 //语义内核
109 "SemanticKernel": { 124 "SemanticKernel": {
110 "ModelIds": ["gpt-4o"], 125 "ModelIds": ["gpt-4o"],
111 "Endpoint": "https://xxx.com/v1", 126 "Endpoint": "https://xxx.com/v1",
112 "ApiKey": "sk-xxxxxx" 127 "ApiKey": "sk-xxxxxx"
  128 + },
  129 +
  130 + "FoodLabeling": {
  131 + "BatchImport": {
  132 + "TemplateDirectory": "/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles",
  133 + "LocationTemplateFileName": "Location-Manager-批量导入模板.xlsx",
  134 + "TeamMemberTemplateFileName": "Team-Member-批量导入模板.xlsx",
  135 + "ProductTemplateFileName": "Product-Manager-批量导入模板.xlsx",
  136 + "TeamMemberImportDefaultPassword": "ChangeMe123!",
  137 + "MaxImportRows": 5000,
  138 + "MaxUploadBytes": 10485760,
  139 + "MaxBulkUpdateItems": 500
  140 + }
113 } 141 }
114 } 142 }
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
@@ -1819,7 +1819,7 @@ function RoleDialog({ @@ -1819,7 +1819,7 @@ function RoleDialog({
1819 } 1819 }
1820 setSubmitting(true); 1820 setSubmitting(true);
1821 try { 1821 try {
1822 - const payload = { 1822 + const payload: RbacRoleUpsertInput = {
1823 roleName: roleName.trim(), 1823 roleName: roleName.trim(),
1824 roleCode: roleCode.trim(), 1824 roleCode: roleCode.trim(),
1825 remark: remark.trim() ? remark.trim() : null, 1825 remark: remark.trim() ? remark.trim() : null,
美国版/Food Labeling Management Platform/src/types/authSession.ts
@@ -27,5 +27,11 @@ export type CurrentUserMenuPermissionsOutputDto = { @@ -27,5 +27,11 @@ export type CurrentUserMenuPermissionsOutputDto = {
27 roleCodes: string[]; 27 roleCodes: string[];
28 permissionCodes: string[]; 28 permissionCodes: string[];
29 menus: CurrentUserMenuNodeDto[]; 29 menus: CurrentUserMenuNodeDto[];
  30 + /** User.LastModificationTime(ISO 字符串或 null) */
  31 + lastUpdated?: string | null;
  32 + /** 角色展示名,多角色英文逗号拼接 */
  33 + role?: string;
  34 + /** 全名:姓名 > 昵称 > 用户名 */
  35 + fullName?: string;
30 }; 36 };
31 37
项目相关文档/批量导入导出接口说明.md
@@ -224,12 +224,14 @@ @@ -224,12 +224,14 @@
224 224
225 **列表筛选字段**(导出与列表对齐):`Keyword`、`State`、`Sorting` — 与产品分页列表一致(`Keyword` 匹配产品编码、名称、分类名称)。 225 **列表筛选字段**(导出与列表对齐):`Keyword`、`State`、`Sorting` — 与产品分页列表一致(`Keyword` 匹配产品编码、名称、分类名称)。
226 226
  227 +**路由说明**:单条 **GET/PUT/DELETE** 的 `{id}` 为 **`Guid` 类型**(与 `fl_product.Id` 的 Guid 字符串一致),约定路由会带 **`{id:guid}`** 约束,因此 **`export-products-excel`、`download-product-import-template`、`import-products-batch`、`update-products-bulk`** 等字面路径不会被误判为产品 Id。
  228 +
227 ### 3.1 下载批量导入模板 229 ### 3.1 下载批量导入模板
228 230
229 | 项目 | 说明 | 231 | 项目 | 说明 |
230 |------|------| 232 |------|------|
231 | 方法 | `DownloadProductImportTemplateAsync` | 233 | 方法 | `DownloadProductImportTemplateAsync` |
232 -| HTTP | `GET` | 234 +| HTTP | `GET`(已标 `[HttpGet]`,与约定 `Download*` 可能判为 `POST` 的情况区分) |
233 | 常见路径 | `/api/app/product/download-product-import-template` | 235 | 常见路径 | `/api/app/product/download-product-import-template` |
234 | 作用 | 从 `TemplateDirectory` 读取 `ProductTemplateFileName` 指向的 xlsx 并下载(工作表名一般为 `Products`) | 236 | 作用 | 从 `TemplateDirectory` 读取 `ProductTemplateFileName` 指向的 xlsx 并下载(工作表名一般为 `Products`) |
235 237
@@ -238,14 +240,17 @@ @@ -238,14 +240,17 @@
238 | 项目 | 说明 | 240 | 项目 | 说明 |
239 |------|------| 241 |------|------|
240 | 方法 | `ExportProductsExcelAsync` | 242 | 方法 | `ExportProductsExcelAsync` |
241 -| HTTP | `GET` | 243 +| HTTP | `GET`(应用服务方法上已标 `[HttpGet]`;ABP 对 `Export*` 默认易判成 `POST`,用 GET 否则会 **405**) |
242 | 常见路径 | `/api/app/product/export-products-excel` | 244 | 常见路径 | `/api/app/product/export-products-excel` |
  245 +| Postman | **不要** 填 Body;筛选用 **Params**(Query)或拼在 URL 上;导出**不需要**上传 `file`(上传文件是 **3.3 导入**) |
243 | Query | 与产品列表筛选一致:`Keyword`、`State`、`Sorting` | 246 | Query | 与产品列表筛选一致:`Keyword`、`State`、`Sorting` |
244 | 数据范围 | **全量**:符合筛选条件的全部产品;**不使用** `SkipCount` / `MaxResultCount` | 247 | 数据范围 | **全量**:符合筛选条件的全部产品;**不使用** `SkipCount` / `MaxResultCount` |
245 | 排序 | 有 `Sorting` 则按其排序,否则默认按 `ProductName` 降序 | 248 | 排序 | 有 `Sorting` 则按其排序,否则默认按 `ProductName` 降序 |
246 | 响应文件名示例 | `products-export-yyyyMMdd-HHmmss.xlsx` | 249 | 响应文件名示例 | `products-export-yyyyMMdd-HHmmss.xlsx` |
247 | 列(与导入模板一致) | **Location**(多门店英文逗号拼接门店名称)、**Product Category**(分类名称)、**Product**(产品名称)、**Product Code**(产品编码;可为空则导出为空单元格) | 250 | 列(与导入模板一致) | **Location**(多门店英文逗号拼接门店名称)、**Product Category**(分类名称)、**Product**(产品名称)、**Product Code**(产品编码;可为空则导出为空单元格) |
248 251
  252 +**部署**:须使用 **`dotnet publish` 后的完整输出目录**(含 `ClosedXML.dll`、`DocumentFormat.OpenXml.dll`、`Yi.Abp.Web.deps.json` 等)部署;**不要用 `bin/Debug`(或 `bin/Release`)里挑文件当上线包**。也**禁止**「本地跑起来后只把若干 dll / 自己改过的文件」覆盖到线上(极易漏掉第三方依赖)。上线建议整包替换发布目录,或在服务器上 `git pull` + `dotnet publish`;发布时若输出里缺少 `ClosedXML.dll`,当前 `Yi.Abp.Web` 工程会在 `dotnet publish` 结束时报错提示。
  253 +
249 ### 3.3 批量导入 Excel 254 ### 3.3 批量导入 Excel
250 255
251 | 项目 | 说明 | 256 | 项目 | 说明 |
@@ -289,7 +294,7 @@ @@ -289,7 +294,7 @@
289 294
290 ## 5 Account Management(Company / Region) 295 ## 5 Account Management(Company / Region)
291 296
292 -前端 **Account Management** 菜单中 **Company**、**Region** 两个页签的「Bulk Export (PDF)」对应后端已有接口:数据模型分别为 **`fl_partner`**(合作伙伴 / 公司)、**`fl_group`**(组织 / 大区);应用服务为 **`PartnerAppService`**、**`GroupAppService`**。导出为 **QuestPDF** 生成的 **PDF**,筛选条件与各自**分页列表**一致,**不使用** `SkipCount` / `MaxResultCount`(全量导出)。单次导出超过 **5000** 条时返回业务错误,需缩小筛选范围。 297 +前端 **Account Management** 菜单中 **Company**、**Region** 两个页签的「Bulk Export (PDF)」对应后端已有接口:数据模型分别为 **`fl_partner`**(合作伙伴 / 公司)、**`fl_group`**(组织 / 大区);应用服务为 **`PartnerAppService`**、**`GroupAppService`**。导出为 **QuestPDF** 生成的 **PDF**,筛选条件与各自**分页列表**一致,**不使用** `SkipCount` / `MaxResultCount`(全量导出)。单次导出超过 **5000** 条时返回业务错误,需缩小筛选范围。单条 **GET/PUT/DELETE** 的 `{id}` 为 **`Guid`**(与 `fl_partner.Id` / `fl_group.Id` 的 Guid 字符串一致),约定路由 **`{id:guid}`**,**`export-pdf`** 等字面路径不会误判为资源 Id;`ExportPdfAsync` 已标 **`[HttpGet]`**。
293 298
294 ### 5.1 Company(合作伙伴 / `PartnerAppService`) 299 ### 5.1 Company(合作伙伴 / `PartnerAppService`)
295 300