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 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 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 88 // 处理原始类型ID
82 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 12 public List<string> PermissionCodes { get; set; } = new();
13 13  
14 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 8  
9 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 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 10 public string? Keyword { get; set; }
11 11  
12 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 19 /// </summary>
15 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 10  
11 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 24 public string LabelCategoryId { get; set; } = string.Empty;
14 25  
15 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 6  
7 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 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 29 public string AvailabilityType { get; set; } = "ALL";
30 30  
31 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 43 /// </summary>
34 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 7 public string? Keyword { get; set; }
8 8  
9 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 18  
19 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 31 public List<string> LocationIds { get; set; } = new();
25 32  
26 33 public int OrderNum { get; set; }
27 34  
  35 + /// <summary>
  36 + /// 该分类下已创建标签数量(<c>fl_label</c> 未删除且 <c>LabelCategoryId</c> 匹配)
  37 + /// </summary>
28 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 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 18  
19 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 27 public List<string> LocationIds { get; set; } = new();
22 28  
23 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 34  
35 35 public decimal? Longitude { get; set; }
36 36  
  37 + /// <summary>
  38 + /// 经营时间(自由文本)
  39 + /// </summary>
  40 + public string? OperatingHours { get; set; }
  41 +
37 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 33  
34 34 public decimal? Longitude { get; set; }
35 35  
  36 + /// <summary>
  37 + /// 经营时间
  38 + /// </summary>
  39 + public string? OperatingHours { get; set; }
  40 +
36 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 33 public decimal? Longitude { get; set; }
34 34  
35 35 /// <summary>
  36 + /// 经营时间(自由文本)
  37 + /// </summary>
  38 + public string? OperatingHours { get; set; }
  39 +
  40 + /// <summary>
36 41 /// 启用状态
37 42 /// </summary>
38 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 3 /// <summary>
4 4 /// 新建合作伙伴入参
5 5 /// </summary>
6   -public class PartnerCreateInputVo
  6 +public class PartnerCreateInputVo : PartnerAddressFieldsDto
7 7 {
8 8 public string PartnerName { get; set; } = string.Empty;
9 9  
... ... @@ -11,5 +11,8 @@ public class PartnerCreateInputVo
11 11  
12 12 public string? PhoneNumber { get; set; }
13 13  
  14 + /// <summary>
  15 + /// 是否启用(Active / Inactive)
  16 + /// </summary>
14 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 3 /// <summary>
4 4 /// 合作伙伴列表项
5 5 /// </summary>
6   -public class PartnerGetListOutputDto
  6 +public class PartnerGetListOutputDto : PartnerAddressFieldsDto
7 7 {
8 8 public string Id { get; set; } = string.Empty;
9 9  
... ... @@ -13,6 +13,9 @@ public class PartnerGetListOutputDto
13 13  
14 14 public string? PhoneNumber { get; set; }
15 15  
  16 + /// <summary>
  17 + /// 是否启用
  18 + /// </summary>
16 19 public bool State { get; set; }
17 20  
18 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 3 /// <summary>
4 4 /// 合作伙伴详情
5 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 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 3 /// <summary>
4 4 /// 编辑合作伙伴入参
5 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 18 public bool State { get; set; } = true;
19 19  
20 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 33 /// </summary>
24 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 17 /// 启用状态
18 18 /// </summary>
19 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 18  
19 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 30 /// <summary>
22   - /// 该产品关联的门店Id列表(来自 fl_location_product)
  31 + /// 适用门店 Id 列表(来自 fl_location_product)
23 32 /// </summary>
24 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 32 public string AvailabilityType { get; set; } = "ALL";
33 33  
34 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 46 /// </summary>
37 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 16 /// 启用状态过滤
17 17 /// </summary>
18 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 24 public int OrderNum { get; set; }
25 25  
26 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 22  
23 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 32 public List<string> LocationIds { get; set; } = new();
26 33  
27 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 19 public bool State { get; set; } = true;
20 20  
21 21 /// <summary>
22   - /// 排序;未传时服务端按 0 处理
  22 + /// 排序号;不传或传 null 时新增默认为 0,编辑时保留原值
23 23 /// </summary>
24 24 public int? OrderNum { get; set; }
25 25  
26 26 /// <summary>
27   - /// 访问权限编码 JSON 数组字符串(如 ["manage_labels","manage_people"])
  27 + /// 绑定菜单 Id;与 accessPermissions 同时传时以本字段为准
28 28 /// </summary>
29   - public string? AccessPermissions { get; set; }
  29 + public List<Guid>? MenuIds { get; set; }
30 30  
31 31 /// <summary>
32   - /// 兼容旧字段名 <c>accessPermissionCodes</c>
  32 + /// 按 PermissionCode 绑定菜单(英文逗号分隔);传空字符串表示清空绑定;不传则不修改已有绑定(仅编辑时)
33 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 20 public int OrderNum { get; set; }
21 21  
22 22 /// <summary>
23   - /// 访问权限编码列表
  23 + /// 已绑定菜单的 PermissionCode 汇总(英文逗号+空格拼接,与 /api/app/role 一致)
24 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 18 public Guid? RoleId { get; set; }
19 19  
20 20 /// <summary>
21   - /// 关联门店(至少1个)
  21 + /// 适用 Company(<c>fl_partner.Id</c>,UI 称 Company);与 <see cref="PartnerIds"/> 合并
22 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 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 21  
22 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 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 16  
17 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 28 public List<string> LocationIds { get; set; } = new();
20 29  
21 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 17  
18 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 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 3  
4 4 <ItemGroup>
5 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 7 <ProjectReference Include="..\FoodLabeling.Domain.Shared\FoodLabeling.Domain.Shared.csproj" />
7 8 <ProjectReference Include="..\..\rbac\Yi.Framework.Rbac.Domain.Shared\Yi.Framework.Rbac.Domain.Shared.csproj" />
8 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 13 /// </summary>
14 14 /// <remarks>
15 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 18 /// </remarks>
17 19 /// <returns>用户简要信息、权限码与菜单树</returns>
18 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 14 /// <summary>
15 15 /// 组织分页列表(与导出使用相同筛选条件)
16 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 21 /// <param name="input">分页与筛选;SkipCount 为页码(从 1 起)</param>
18 22 Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input);
19 23  
20 24 /// <summary>
21 25 /// 组织详情
22 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 30 /// <summary>
26 31 /// 新增组织
... ... @@ -30,16 +35,19 @@ public interface IGroupAppService : IApplicationService
30 35 /// <summary>
31 36 /// 编辑组织
32 37 /// </summary>
33   - Task<GroupGetOutputDto> UpdateAsync(string id, GroupUpdateInputVo input);
  38 + Task<GroupGetOutputDto> UpdateAsync(Guid id, GroupUpdateInputVo input);
34 39  
35 40 /// <summary>
36 41 /// 删除组织(逻辑删除)
37 42 /// </summary>
38   - Task DeleteAsync(string id);
  43 + Task DeleteAsync(Guid id);
39 44  
40 45 /// <summary>
41   - /// 按列表相同筛选条件全量导出组织(Region)为 PDF(不分页;与 <see cref="GetListAsync"/> 相同筛选;单次最多 5000 条)
  46 + /// 按列表相同筛选条件全量导出组织(Region)为 PDF(不分页;与 <see cref="GetListAsync"/> 相同筛选与 Token 数据范围;单次最多 5000 条)
42 47 /// </summary>
  48 + /// <remarks>
  49 + /// 与 <see cref="GetListAsync"/> 使用同一套 <c>BuildGroupJoinedQueryAsync</c>(含 <c>ResolveGroupScopeAsync</c>),导出行集与列表一致。
  50 + /// </remarks>
43 51 /// <param name="input">Keyword、PartnerId、State、Sorting;分页字段忽略</param>
44 52 /// <returns>application/pdf</returns>
45 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 14 /// <summary>
15 15 /// 合作伙伴分页列表(与导出使用相同筛选条件)
16 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 24 /// <param name="input">分页与筛选;SkipCount 为页码(从 1 起)</param>
18 25 /// <returns>分页数据</returns>
19 26 /// <response code="200">成功</response>
... ... @@ -24,17 +31,17 @@ public interface IPartnerAppService : IApplicationService
24 31 /// <summary>
25 32 /// 合作伙伴详情
26 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 35 /// <returns>详情</returns>
29 36 /// <response code="200">成功</response>
30 37 /// <response code="400">Id 无效</response>
31 38 /// <response code="500">服务器错误</response>
32   - Task<PartnerGetOutputDto> GetAsync(string id);
  39 + Task<PartnerGetOutputDto> GetAsync(Guid id);
33 40  
34 41 /// <summary>
35 42 /// 新增合作伙伴
36 43 /// </summary>
37   - /// <param name="input">名称、邮箱、电话、启用状态</param>
  44 + /// <param name="input">名称、联系信息、地址、启用状态</param>
38 45 /// <returns>新建后的详情</returns>
39 46 /// <remarks>
40 47 /// 示例请求:
... ... @@ -43,9 +50,15 @@ public interface IPartnerAppService : IApplicationService
43 50 /// "partnerName": "Global Foods Inc.",
44 51 /// "contactEmail": "admin@globalfoods.com",
45 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 58 /// "state": true
47 59 /// }
48 60 /// ```
  61 + /// 地址中的州/省请传 <c>stateCode</c>(如 NY);<c>state</c>(boolean)表示是否启用。
49 62 /// </remarks>
50 63 /// <response code="200">成功</response>
51 64 /// <response code="400">校验失败</response>
... ... @@ -56,12 +69,12 @@ public interface IPartnerAppService : IApplicationService
56 69 /// 编辑合作伙伴
57 70 /// </summary>
58 71 /// <param name="id">主键 Id</param>
59   - /// <param name="input">名称、邮箱、电话、启用状态</param>
  72 + /// <param name="input">名称、联系信息、地址、启用状态(字段同新增)</param>
60 73 /// <returns>更新后的详情</returns>
61 74 /// <response code="200">成功</response>
62 75 /// <response code="400">校验失败或记录不存在</response>
63 76 /// <response code="500">服务器错误</response>
64   - Task<PartnerGetOutputDto> UpdateAsync(string id, PartnerUpdateInputVo input);
  77 + Task<PartnerGetOutputDto> UpdateAsync(Guid id, PartnerUpdateInputVo input);
65 78  
66 79 /// <summary>
67 80 /// 删除合作伙伴(逻辑删除)
... ... @@ -70,7 +83,7 @@ public interface IPartnerAppService : IApplicationService
70 83 /// <response code="200">成功</response>
71 84 /// <response code="400">Id 无效或记录不存在</response>
72 85 /// <response code="500">服务器错误</response>
73   - Task DeleteAsync(string id);
  86 + Task DeleteAsync(Guid id);
74 87  
75 88 /// <summary>
76 89 /// 按当前列表筛选条件批量导出合作伙伴为 PDF(Account Management「Company」页签;不分页,上限 5000 条)
... ... @@ -78,7 +91,7 @@ public interface IPartnerAppService : IApplicationService
78 91 /// <param name="input">与列表相同的 Keyword、State;分页字段忽略</param>
79 92 /// <returns>PDF 文件流</returns>
80 93 /// <remarks>
81   - /// 筛选条件需与 <see cref="GetListAsync"/> 一致,便于统计与导出数据对齐
  94 + /// 筛选条件与数据范围需与 <see cref="GetListAsync"/> 完全一致(含 Token 权限:管理员全部公司,其它角色仅绑定门店所属公司;见 PartnerScopeHelper)
82 95 /// </remarks>
83 96 /// <response code="200">成功返回 application/pdf</response>
84 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 14 /// <summary>
15 15 /// 产品分页列表
16 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 22 Task<PagedResultWithPageDto<ProductGetListOutputDto>> GetListAsync(ProductGetListInputVo input);
18 23  
19 24 /// <summary>
20 25 /// 产品详情
21 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 30 /// <summary>
25 31 /// 新增产品
26 32 /// </summary>
27 33 /// <remarks>
28 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 37 /// </remarks>
31 38 Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input);
32 39  
... ... @@ -34,15 +41,15 @@ public interface IProductAppService : IApplicationService
34 41 /// 编辑产品
35 42 /// </summary>
36 43 /// <remarks>
37   - /// 当请求体包含 <see cref="ProductCreateInputVo.LocationIds"/> 属性时,按该列表整表替换本产品在各门店的关联;
38   - /// 不传该属性则不改门店关联(兼容仅改名称/分类等调用)。
  44 + /// 当请求体包含 <see cref="ProductCreateInputVo.PartnerId"/>、<see cref="ProductCreateInputVo.GroupIds"/>
  45 + /// 和/或 <see cref="ProductCreateInputVo.LocationIds"/> 时,合并后整表替换门店关联;均不传则不改。
39 46 /// </remarks>
40   - Task<ProductGetOutputDto> UpdateAsync(string id, ProductUpdateInputVo input);
  47 + Task<ProductGetOutputDto> UpdateAsync(Guid id, ProductUpdateInputVo input);
41 48  
42 49 /// <summary>
43 50 /// 删除产品(逻辑删除)
44 51 /// </summary>
45   - Task DeleteAsync(string id);
  52 + Task DeleteAsync(Guid id);
46 53  
47 54 /// <summary>
48 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 9 /// </summary>
10 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 15 Task<PagedResultWithPageDto<ProductCategoryGetListOutputDto>> GetListAsync(ProductCategoryGetListInputVo input);
13 16  
14 17 Task<ProductCategoryGetOutputDto> GetAsync(string id);
15 18  
  19 + /// <summary>
  20 + /// 新增类别;body 传 <c>regionIds</c>(Region 多选)与 <c>locationIds</c>(门店多选)绑定适用范围。
  21 + /// </summary>
16 22 Task<ProductCategoryGetOutputDto> CreateAsync(ProductCategoryCreateInputVo input);
17 23  
  24 + /// <summary>
  25 + /// 编辑类别;<c>regionIds</c>/<c>locationIds</c> 多选数组规则同新增;传空数组 <c>[]</c> 可清空对应范围。
  26 + /// </summary>
18 27 Task<ProductCategoryGetOutputDto> UpdateAsync(string id, ProductCategoryUpdateInputVo input);
19 28  
20 29 Task DeleteAsync(string id);
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs
1 1 using FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
2 2 using Volo.Abp.Application.Services;
  3 +using Yi.Framework.Rbac.Application.Contracts.Dtos.Account;
3 4  
4 5 namespace FoodLabeling.Application.Contracts.IServices;
5 6  
... ... @@ -48,4 +49,26 @@ public interface IUsAppAuthAppService : IApplicationService
48 49 /// <response code="400">参数非法、未绑定或无权限</response>
49 50 /// <response code="500">服务器错误</response>
50 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 9  
10 10 <ItemGroup>
11 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 13 <ProjectReference Include="..\FoodLabeling.Application.Contracts\FoodLabeling.Application.Contracts.csproj" />
13 14 <ProjectReference Include="..\FoodLabeling.Domain\FoodLabeling.Domain.csproj" />
14 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 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 59 List<MenuDbEntity> menus;
54 60 if (UserConst.Admin.Equals(user.UserName))
55 61 {
... ... @@ -60,12 +66,7 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
60 66 }
61 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 70 if (roleIdStrs.Count == 0)
70 71 {
71 72 menus = new List<MenuDbEntity>();
... ... @@ -92,10 +93,11 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
92 93 .ThenBy(x => x.MenuName)
93 94 .ToList();
94 95  
95   - // 注意:查询 RoleAggregateRoot 会触发 YiRbacDbContext 的 IDataPermission 过滤,
  96 + // 注意:经仓储查询 RoleAggregateRoot 会触发 YiRbacDbContext 的 IDataPermission 过滤,
96 97 // 其表达式包含 roleInfo.Select(...).Contains(...),在当前 SqlSugar 版本下会报“不支持 Select”。
97   - // 这里直接使用 JWT 中的角色码(CurrentUser.Roles)返回,避免触发过滤器
  98 + // 角色展示名使用 RoleDbEntity 直查 Role 表;角色编码列表仍用 JWT(CurrentUser.Roles),与原先 RoleCodes 行为一致
98 99 var roleCodes = CurrentUser.Roles?.ToList() ?? new List<string>();
  100 + var roleDisplay = await BuildRoleDisplayAsync(distinctUserRoleIds, roleCodes);
99 101  
100 102 var permissionCodes = menuNodes
101 103 .Where(x => !string.IsNullOrWhiteSpace(x.PermissionCode))
... ... @@ -116,7 +118,10 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
116 118 },
117 119 RoleCodes = roleCodes.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().OrderBy(x => x).ToList(),
118 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 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 202 private static List<CurrentUserMenuNodeDto> BuildMenuTree(List<CurrentUserMenuNodeDto> flat)
137 203 {
138 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 36 /// </summary>
37 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 54 /// <summary>
40 55 /// 是否启用(对应 UI Active)
41 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 36 public async Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input)
37 37 {
38 38 RefAsync<int> total = 0;
39   - var query = BuildGroupJoinedQuery(input);
  39 + var query = await BuildGroupJoinedQueryAsync(input);
40 40 var projected = query.Select((g, p) => new GroupGetListOutputDto
41 41 {
42 42 Id = g.Id,
43 43 GroupName = g.GroupName,
44 44 PartnerId = g.PartnerId,
45   - PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "" : p.PartnerName.Trim(),
  45 + PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "None" : p.PartnerName.Trim(),
46 46 State = g.State,
47 47 CreationTime = g.CreationTime
48 48 });
... ... @@ -52,19 +52,19 @@ public class GroupAppService : ApplicationService, IGroupAppService
52 52 }
53 53  
54 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 63 var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
64 64 .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
65 65 if (entity is null)
66 66 {
67   - throw new UserFriendlyException("组织不存在");
  67 + throw new UserFriendlyException("Group not found.");
68 68 }
69 69  
70 70 var partnerName = await ResolvePartnerNameAsync(entity.PartnerId);
... ... @@ -78,13 +78,13 @@ public class GroupAppService : ApplicationService, IGroupAppService
78 78 var name = input.GroupName?.Trim();
79 79 if (string.IsNullOrWhiteSpace(name))
80 80 {
81   - throw new UserFriendlyException("组织名称不能为空");
  81 + throw new UserFriendlyException("Region name is required.");
82 82 }
83 83  
84 84 var partnerId = input.PartnerId?.Trim();
85 85 if (string.IsNullOrWhiteSpace(partnerId))
86 86 {
87   - throw new UserFriendlyException("请选择所属合作伙伴");
  87 + throw new UserFriendlyException("Parent company is required.");
88 88 }
89 89  
90 90 await EnsurePartnerExistsAsync(partnerId);
... ... @@ -104,36 +104,36 @@ public class GroupAppService : ApplicationService, IGroupAppService
104 104 };
105 105  
106 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 110 /// <inheritdoc />
111 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 120 var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
121 121 .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
122 122 if (entity is null)
123 123 {
124   - throw new UserFriendlyException("组织不存在");
  124 + throw new UserFriendlyException("Group not found.");
125 125 }
126 126  
127 127 var name = input.GroupName?.Trim();
128 128 if (string.IsNullOrWhiteSpace(name))
129 129 {
130   - throw new UserFriendlyException("组织名称不能为空");
  130 + throw new UserFriendlyException("Region name is required.");
131 131 }
132 132  
133 133 var partnerId = input.PartnerId?.Trim();
134 134 if (string.IsNullOrWhiteSpace(partnerId))
135 135 {
136   - throw new UserFriendlyException("请选择所属合作伙伴");
  136 + throw new UserFriendlyException("Parent company is required.");
137 137 }
138 138  
139 139 await EnsurePartnerExistsAsync(partnerId);
... ... @@ -145,24 +145,24 @@ public class GroupAppService : ApplicationService, IGroupAppService
145 145 entity.LastModifierId = CurrentUser?.Id?.ToString();
146 146  
147 147 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
148   - return await GetAsync(groupId);
  148 + return await GetAsync(id);
149 149 }
150 150  
151 151 /// <inheritdoc />
152 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 161 var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
162 162 .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
163 163 if (entity is null)
164 164 {
165   - throw new UserFriendlyException("组织不存在");
  165 + throw new UserFriendlyException("Group not found.");
166 166 }
167 167  
168 168 entity.IsDeleted = true;
... ... @@ -172,24 +172,26 @@ public class GroupAppService : ApplicationService, IGroupAppService
172 172 }
173 173  
174 174 /// <inheritdoc />
175   - public async Task<IActionResult> ExportPdfAsync(GroupGetListInputVo input)
  175 + [HttpGet]
  176 + public async Task<IActionResult> ExportPdfAsync([FromQuery] GroupGetListInputVo input)
176 177 {
177 178 QuestPDF.Settings.License = LicenseType.Community;
178 179  
179   - var exportBase = BuildGroupJoinedQuery(input);
  180 + var exportBase = await BuildGroupJoinedQueryAsync(input);
180 181 var count = await exportBase.CountAsync();
181 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 189 .Select((g, p) => new GroupGetListOutputDto
188 190 {
189 191 Id = g.Id,
190 192 GroupName = g.GroupName,
191 193 PartnerId = g.PartnerId,
192   - PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "" : p.PartnerName.Trim(),
  194 + PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "None" : p.PartnerName.Trim(),
193 195 State = g.State,
194 196 CreationTime = g.CreationTime
195 197 })
... ... @@ -228,7 +230,7 @@ public class GroupAppService : ApplicationService, IGroupAppService
228 230 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
229 231 .Text(e.GroupName ?? string.Empty);
230 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 234 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status);
233 235 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
234 236 .Text(e.CreationTime.ToString("yyyy-MM-dd HH:mm"));
... ... @@ -243,14 +245,21 @@ public class GroupAppService : ApplicationService, IGroupAppService
243 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 253 var keyword = input.Keyword?.Trim();
249 254 var partnerId = input.PartnerId?.Trim();
250 255  
251 256 var query = _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
252 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 263 .WhereIF(input.State != null, (g, p) => g.State == input.State)
255 264 .WhereIF(!string.IsNullOrWhiteSpace(partnerId), (g, p) => g.PartnerId == partnerId)
256 265 .WhereIF(!string.IsNullOrWhiteSpace(keyword),
... ... @@ -311,7 +320,7 @@ public class GroupAppService : ApplicationService, IGroupAppService
311 320 .AnyAsync(x => !x.IsDeleted && x.Id == partnerId);
312 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 330 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
322 331 if (p is null || string.IsNullOrWhiteSpace(p.PartnerName))
323 332 {
324   - return "";
  333 + return "None";
325 334 }
326 335  
327 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 34 RefAsync<int> total = 0;
35 35  
36 36 var productId = input.ProductId?.Trim();
  37 + var groupId = input.GroupId?.Trim();
37 38 var locationId = input.LocationId?.Trim();
38 39 var keyword = input.Keyword?.Trim();
39 40 var labelCategoryId = input.LabelCategoryId?.Trim();
... ... @@ -45,7 +46,6 @@ public class LabelAppService : ApplicationService, ILabelAppService
45 46  
46 47 var labelIdsQuery = _dbContext.SqlSugarClient.Queryable<FlLabelDbEntity>()
47 48 .Where(l => !l.IsDeleted)
48   - .WhereIF(!string.IsNullOrWhiteSpace(locationId), l => l.LocationId == locationId)
49 49 .WhereIF(!string.IsNullOrWhiteSpace(labelCategoryId), l => l.LabelCategoryId == labelCategoryId)
50 50 .WhereIF(!string.IsNullOrWhiteSpace(labelTypeId), l => l.LabelTypeId == labelTypeId)
51 51 .WhereIF(input.State != null, l => l.State == input.State);
... ... @@ -58,6 +58,15 @@ public class LabelAppService : ApplicationService, ILabelAppService
58 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 70 // 按产品筛选:存在 label-product 关联即可
62 71 if (!string.IsNullOrWhiteSpace(productId))
63 72 {
... ... @@ -259,12 +268,25 @@ public class LabelAppService : ApplicationService, ILabelAppService
259 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 280 return new LabelGetOutputDto
263 281 {
264 282 Id = label.LabelCode ?? string.Empty,
265 283 LabelName = label.LabelName,
266   - LocationId = label.LocationId ?? string.Empty,
  284 + LocationId = locationId,
267 285 LocationName = location?.LocationName ?? location?.LocationCode ?? "无",
  286 + PartnerId = partnerIds.Count > 0 ? partnerIds[0] : null,
  287 + PartnerIds = partnerIds,
  288 + RegionIds = regionIds,
  289 + GroupIds = regionIds,
268 290 LabelCategoryId = label.LabelCategoryId ?? string.Empty,
269 291 LabelCategoryName = category?.CategoryName ?? "无",
270 292 LabelTypeId = label.LabelTypeId ?? string.Empty,
... ... @@ -301,10 +323,6 @@ public class LabelAppService : ApplicationService, ILabelAppService
301 323 {
302 324 throw new UserFriendlyException("模板编码不能为空");
303 325 }
304   - if (string.IsNullOrWhiteSpace(input.LocationId))
305   - {
306   - throw new UserFriendlyException("门店Id不能为空");
307   - }
308 326 if (string.IsNullOrWhiteSpace(input.LabelCategoryId))
309 327 {
310 328 throw new UserFriendlyException("标签分类Id不能为空");
... ... @@ -314,6 +332,8 @@ public class LabelAppService : ApplicationService, ILabelAppService
314 332 throw new UserFriendlyException("标签类型Id不能为空");
315 333 }
316 334  
  335 + var resolvedLocationId = await ResolveLabelLocationIdForSaveAsync(input);
  336 +
317 337 var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
318 338 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim());
319 339 if (template is null)
... ... @@ -344,7 +364,7 @@ public class LabelAppService : ApplicationService, ILabelAppService
344 364 LabelCode = labelCode,
345 365 LabelName = labelName,
346 366 TemplateId = template.Id,
347   - LocationId = input.LocationId?.Trim(),
  367 + LocationId = resolvedLocationId,
348 368 LabelCategoryId = input.LabelCategoryId?.Trim(),
349 369 LabelTypeId = input.LabelTypeId?.Trim(),
350 370 State = input.State,
... ... @@ -363,6 +383,9 @@ public class LabelAppService : ApplicationService, ILabelAppService
363 383 }).ToList();
364 384 await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync();
365 385  
  386 + await LabelCategoryAppService.TouchLabelCategoryLastEditedAsync(
  387 + _dbContext, labelEntity.LabelCategoryId, CurrentUser?.Id?.ToString());
  388 +
366 389 return await GetAsync(labelCode);
367 390 }
368 391  
... ... @@ -391,10 +414,6 @@ public class LabelAppService : ApplicationService, ILabelAppService
391 414 {
392 415 throw new UserFriendlyException("模板编码不能为空");
393 416 }
394   - if (string.IsNullOrWhiteSpace(input.LocationId))
395   - {
396   - throw new UserFriendlyException("门店Id不能为空");
397   - }
398 417 if (string.IsNullOrWhiteSpace(input.LabelCategoryId))
399 418 {
400 419 throw new UserFriendlyException("标签分类Id不能为空");
... ... @@ -404,6 +423,8 @@ public class LabelAppService : ApplicationService, ILabelAppService
404 423 throw new UserFriendlyException("标签类型Id不能为空");
405 424 }
406 425  
  426 + var resolvedLocationId = await ResolveLabelLocationIdForSaveAsync(input);
  427 +
407 428 var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
408 429 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim());
409 430 if (template is null)
... ... @@ -411,10 +432,11 @@ public class LabelAppService : ApplicationService, ILabelAppService
411 432 throw new UserFriendlyException("模板不存在");
412 433 }
413 434  
  435 + var oldCategoryId = label.LabelCategoryId;
414 436 var now = DateTime.Now;
415 437 label.LabelName = input.LabelName?.Trim() ?? label.LabelName;
416 438 label.TemplateId = template.Id;
417   - label.LocationId = input.LocationId?.Trim();
  439 + label.LocationId = resolvedLocationId;
418 440 label.LabelCategoryId = input.LabelCategoryId?.Trim();
419 441 label.LabelTypeId = input.LabelTypeId?.Trim();
420 442 label.State = input.State;
... ... @@ -438,6 +460,15 @@ public class LabelAppService : ApplicationService, ILabelAppService
438 460 }).ToList();
439 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 472 return await GetAsync(labelCode);
442 473 }
443 474  
... ... @@ -465,6 +496,9 @@ public class LabelAppService : ApplicationService, ILabelAppService
465 496 await _dbContext.SqlSugarClient.Deleteable<FlLabelProductDbEntity>()
466 497 .Where(x => x.LabelId == label.Id)
467 498 .ExecuteCommandAsync();
  499 +
  500 + await LabelCategoryAppService.TouchLabelCategoryLastEditedAsync(
  501 + _dbContext, label.LabelCategoryId, CurrentUser?.Id?.ToString());
468 502 }
469 503  
470 504 /// <summary>
... ... @@ -730,5 +764,86 @@ public class LabelAppService : ApplicationService, ILabelAppService
730 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 3 using FoodLabeling.Application.Contracts.Dtos.LabelCategory;
4 4 using FoodLabeling.Application.Contracts.IServices;
5 5 using FoodLabeling.Application.Services.DbModels;
  6 +using FoodLabeling.Domain.Entities;
6 7 using SqlSugar;
7 8 using Volo.Abp;
8 9 using Volo.Abp.Application.Services;
... ... @@ -34,6 +35,8 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
34 35 (x.DisplayText != null && x.DisplayText.Contains(keyword!)))
35 36 .WhereIF(input.State != null, x => x.State == input.State);
36 37  
  38 + query = await ApplyCategoryScopeFilterAsync(query, input.GroupId, input.LocationId);
  39 +
37 40 // Sorting 仅允许白名单字段,避免 Unknown column/注入风险
38 41 if (!string.IsNullOrWhiteSpace(input.Sorting))
39 42 {
... ... @@ -67,57 +70,33 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
67 70 var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
68 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 100 }).ToList();
122 101  
123 102 return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
... ... @@ -139,7 +118,11 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
139 118 .Where(x => x.CategoryId == entity.Id)
140 119 .Select(x => x.LocationId)
141 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 128 return dto;
... ... @@ -156,9 +139,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
156 139  
157 140 var displayText = input.DisplayText?.Trim();
158 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 144 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlLabelCategoryDbEntity>()
164 145 .AnyAsync(x => !x.IsDeleted && (x.CategoryCode == code || x.CategoryName == name));
... ... @@ -172,6 +153,12 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
172 153 var entity = new FlLabelCategoryDbEntity
173 154 {
174 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 162 CategoryCode = code,
176 163 CategoryName = name,
177 164 DisplayText = string.IsNullOrWhiteSpace(displayText) ? null : displayText,
... ... @@ -183,7 +170,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
183 170 };
184 171  
185 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 174 return await GetAsync(entity.Id);
188 175 }
189 176  
... ... @@ -205,9 +192,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
205 192  
206 193 var displayText = input.DisplayText?.Trim();
207 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 197 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlLabelCategoryDbEntity>()
213 198 .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.CategoryCode == code || x.CategoryName == name));
... ... @@ -228,7 +213,8 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
228 213 entity.LastModifierId = CurrentUser?.Id?.ToString();
229 214  
230 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 218 return await GetAsync(id);
233 219 }
234 220  
... ... @@ -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 276 if (availabilityType != "ALL" && availabilityType != "SPECIFIED")
276 277 {
277 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 377 .Where(x => !string.IsNullOrWhiteSpace(x))
290 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 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 101 Email = input.Email?.Trim(),
102 102 Latitude = input.Latitude,
103 103 Longitude = input.Longitude,
  104 + OperatingHours = input.OperatingHours?.Trim(),
104 105 State = input.State
105 106 };
106 107  
... ... @@ -136,6 +137,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
136 137 entity.Email = input.Email?.Trim();
137 138 entity.Latitude = input.Latitude;
138 139 entity.Longitude = input.Longitude;
  140 + entity.OperatingHours = input.OperatingHours?.Trim();
139 141 entity.State = input.State;
140 142  
141 143 await _locationRepository.UpdateAsync(entity);
... ... @@ -347,7 +349,8 @@ public class LocationAppService : ApplicationService, ILocationAppService
347 349 (x.Country != null && x.Country.Contains(keyword!)) ||
348 350 (x.ZipCode != null && x.ZipCode.Contains(keyword!)) ||
349 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 371 Email = x.Email,
369 372 Latitude = x.Latitude,
370 373 Longitude = x.Longitude,
  374 + OperatingHours = x.OperatingHours,
371 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 36 public async Task<PagedResultWithPageDto<PartnerGetListOutputDto>> GetListAsync(PartnerGetListInputVo input)
37 37 {
38 38 RefAsync<int> total = 0;
39   - var query = BuildPartnerListQuery(input);
  39 + var query = await BuildPartnerListQueryAsync(input);
40 40  
41 41 var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
42 42 var items = entities.Select(MapListItem).ToList();
... ... @@ -44,19 +44,19 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
44 44 }
45 45  
46 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 55 var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
56 56 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
57 57 if (entity is null)
58 58 {
59   - throw new UserFriendlyException("合作伙伴不存在");
  59 + throw new UserFriendlyException("Partner not found.");
60 60 }
61 61  
62 62 return MapDetail(entity);
... ... @@ -69,13 +69,13 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
69 69 var name = input.PartnerName?.Trim();
70 70 if (string.IsNullOrWhiteSpace(name))
71 71 {
72   - throw new UserFriendlyException("合作伙伴名称不能为空");
  72 + throw new UserFriendlyException("Partner name is required.");
73 73 }
74 74  
75 75 var email = input.ContactEmail?.Trim();
76 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 81 var now = Clock.Now;
... ... @@ -85,73 +85,75 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
85 85 IsDeleted = false,
86 86 PartnerName = name,
87 87 ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email,
88   - PhoneNumber = string.IsNullOrWhiteSpace(input.PhoneNumber) ? null : input.PhoneNumber.Trim(),
  88 + PhoneNumber = TrimToNull(input.PhoneNumber),
89 89 State = input.State,
90 90 CreationTime = now,
91 91 CreatorId = CurrentUser?.Id?.ToString(),
92 92 LastModificationTime = now,
93 93 LastModifierId = CurrentUser?.Id?.ToString()
94 94 };
  95 + ApplyAddressFields(entity, input);
95 96  
96 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 101 /// <inheritdoc />
101 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 111 var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
111 112 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
112 113 if (entity is null)
113 114 {
114   - throw new UserFriendlyException("合作伙伴不存在");
  115 + throw new UserFriendlyException("Partner not found.");
115 116 }
116 117  
117 118 var name = input.PartnerName?.Trim();
118 119 if (string.IsNullOrWhiteSpace(name))
119 120 {
120   - throw new UserFriendlyException("合作伙伴名称不能为空");
  121 + throw new UserFriendlyException("Partner name is required.");
121 122 }
122 123  
123 124 var email = input.ContactEmail?.Trim();
124 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 130 entity.PartnerName = name;
130 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 133 entity.State = input.State;
  134 + ApplyAddressFields(entity, input);
133 135 entity.LastModificationTime = Clock.Now;
134 136 entity.LastModifierId = CurrentUser?.Id?.ToString();
135 137  
136 138 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
137   - return await GetAsync(partnerId);
  139 + return await GetAsync(id);
138 140 }
139 141  
140 142 /// <inheritdoc />
141 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 152 var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
151 153 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
152 154 if (entity is null)
153 155 {
154   - throw new UserFriendlyException("合作伙伴不存在");
  156 + throw new UserFriendlyException("Partner not found.");
155 157 }
156 158  
157 159 entity.IsDeleted = true;
... ... @@ -161,15 +163,17 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
161 163 }
162 164  
163 165 /// <inheritdoc />
164   - public async Task<IActionResult> ExportPdfAsync(PartnerGetListInputVo input)
  166 + [HttpGet]
  167 + public async Task<IActionResult> ExportPdfAsync([FromQuery] PartnerGetListInputVo input)
165 168 {
166 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 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 179 var rows = await query.Take(ExportPdfMaxRows).ToListAsync();
... ... @@ -208,9 +212,9 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
208 212 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
209 213 .Text(e.PartnerName ?? string.Empty);
210 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 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 218 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status);
215 219 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
216 220 .Text(e.CreationTime.ToString("yyyy-MM-dd HH:mm"));
... ... @@ -225,16 +229,27 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
225 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 236 var keyword = input.Keyword?.Trim();
231 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 243 .WhereIF(input.State != null, x => x.State == input.State)
234 244 .WhereIF(!string.IsNullOrWhiteSpace(keyword),
235 245 x => x.PartnerName.Contains(keyword!) ||
236 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 254 if (!string.IsNullOrWhiteSpace(input.Sorting))
240 255 {
... ... @@ -276,26 +291,60 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
276 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 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 36 _batchImportOptions = batchImportOptions;
37 37 }
38 38  
  39 + /// <inheritdoc />
39 40 public async Task<PagedResultWithPageDto<ProductGetListOutputDto>> GetListAsync(ProductGetListInputVo input)
40 41 {
41 42 RefAsync<int> total = 0;
42 43  
43   - var query = BuildFilteredProductQuery(input);
  44 + var query = await BuildFilteredProductQueryAsync(input);
44 45 if (!string.IsNullOrWhiteSpace(input.Sorting))
45 46 {
46 47 query = query.OrderBy(input.Sorting);
... ... @@ -107,14 +108,14 @@ public class ProductAppService : ApplicationService, IProductAppService
107 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 115 throw new UserFriendlyException("产品Id不能为空");
116 116 }
117 117  
  118 + var productId = id.ToString();
118 119 var entity = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
119 120 .FirstAsync(x => !x.IsDeleted && x.Id == productId);
120 121  
... ... @@ -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 148 return new ProductGetOutputDto
144 149 {
... ... @@ -149,6 +154,9 @@ public class ProductAppService : ApplicationService, IProductAppService
149 154 CategoryName = categoryName,
150 155 ProductImageUrl = entity.ProductImageUrl,
151 156 State = entity.State,
  157 + PartnerId = partnerIds.Count > 0 ? partnerIds[0] : null,
  158 + PartnerIds = partnerIds,
  159 + GroupIds = groupIds,
152 160 LocationIds = locationIds
153 161 };
154 162 }
... ... @@ -190,24 +198,24 @@ public class ProductAppService : ApplicationService, IProductAppService
190 198  
191 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 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 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 215 throw new UserFriendlyException("产品Id不能为空");
209 216 }
210 217  
  218 + var productId = id.ToString();
211 219 var entity = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
212 220 .FirstAsync(x => !x.IsDeleted && x.Id == productId);
213 221 if (entity is null)
... ... @@ -246,24 +254,24 @@ public class ProductAppService : ApplicationService, IProductAppService
246 254  
247 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 260 await ReplaceProductLocationLinksAsync(productId, locIds);
253 261 }
254 262  
255   - return await GetAsync(productId);
  263 + return await GetAsync(id);
256 264 }
257 265  
258 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 271 throw new UserFriendlyException("产品Id不能为空");
265 272 }
266 273  
  274 + var productId = id.ToString();
267 275 var entity = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
268 276 .FirstAsync(x => !x.IsDeleted && x.Id == productId);
269 277 if (entity is null)
... ... @@ -276,6 +284,7 @@ public class ProductAppService : ApplicationService, IProductAppService
276 284 }
277 285  
278 286 /// <inheritdoc />
  287 + [HttpGet]
279 288 public Task<IActionResult> DownloadProductImportTemplateAsync()
280 289 {
281 290 var opt = _batchImportOptions.Value;
... ... @@ -306,16 +315,20 @@ public class ProductAppService : ApplicationService, IProductAppService
306 315 }
307 316  
308 317 /// <inheritdoc />
  318 + [HttpGet]
309 319 public async Task<IActionResult> ExportProductsExcelAsync([FromQuery] ProductGetListInputVo input)
310 320 {
311 321 var exportFilter = new ProductGetListInputVo
312 322 {
313 323 Sorting = input.Sorting,
314 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 332 if (!string.IsNullOrWhiteSpace(exportFilter.Sorting))
320 333 {
321 334 query = query.OrderBy(exportFilter.Sorting);
... ... @@ -334,6 +347,7 @@ public class ProductAppService : ApplicationService, IProductAppService
334 347 }
335 348  
336 349 /// <inheritdoc />
  350 + [HttpPost]
337 351 public async Task<ProductBatchImportResultDto> ImportProductsBatchAsync(
338 352 [FromForm] ProductBatchImportInputVo input)
339 353 {
... ... @@ -423,10 +437,14 @@ public class ProductAppService : ApplicationService, IProductAppService
423 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 445 if (effectiveCount == 0)
428 446 {
429   - throw new UserFriendlyException("没有有效的产品 Id(请为待保存行填写 id)");
  447 + throw new UserFriendlyException("没有有效的产品 Id(请为待保存行填写 Guid 格式 id)");
430 448 }
431 449  
432 450 var result = new ProductBulkUpdateResultDto();
... ... @@ -438,9 +456,14 @@ public class ProductAppService : ApplicationService, IProductAppService
438 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 464 try
442 465 {
443   - await UpdateAsync(item.Id.Trim(), item);
  466 + await UpdateAsync(productGuid, item);
444 467 result.SuccessCount++;
445 468 }
446 469 catch (UserFriendlyException ex)
... ... @@ -458,7 +481,7 @@ public class ProductAppService : ApplicationService, IProductAppService
458 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 486 var keyword = input.Keyword?.Trim();
464 487  
... ... @@ -467,6 +490,22 @@ public class ProductAppService : ApplicationService, IProductAppService
467 490 .Where(x => !x.IsDeleted)
468 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 509 if (!string.IsNullOrWhiteSpace(keyword))
471 510 {
472 511 query = query
... ... @@ -481,6 +520,66 @@ public class ProductAppService : ApplicationService, IProductAppService
481 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 583 private async Task<List<ProductBatchExcelHelper.ExportRow>> BuildProductExcelExportRowsAsync(
485 584 List<FlProductDbEntity> entities)
486 585 {
... ... @@ -657,40 +756,23 @@ public class ProductAppService : ApplicationService, IProductAppService
657 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 764 /// <summary>
661   - /// 去重、校验门店 Id 格式与存在性。
  765 + /// 合并 Company(partnerId)、Region(groupIds)、门店(locationIds)并校验存在性。
662 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 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 3 using FoodLabeling.Application.Contracts.Dtos.ProductCategory;
4 4 using FoodLabeling.Application.Contracts.IServices;
5 5 using FoodLabeling.Application.Services.DbModels;
  6 +using FoodLabeling.Domain.Entities;
6 7 using SqlSugar;
7 8 using Volo.Abp;
8 9 using Volo.Abp.Application.Services;
... ... @@ -40,6 +41,8 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
40 41 (x.DisplayText != null && x.DisplayText.Contains(keyword!)))
41 42 .WhereIF(input.State != null, x => x.State == input.State);
42 43  
  44 + query = await ApplyCategoryScopeFilterAsync(query, input.GroupId, input.LocationId);
  45 +
43 46 // Sorting 仅允许白名单字段,避免不同数据库列命名导致 Unknown column
44 47 // 同时避免将 input.Sorting 原样拼接到 SQL(存在注入风险)
45 48 if (!string.IsNullOrWhiteSpace(input.Sorting))
... ... @@ -74,18 +77,28 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
74 77  
75 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 102 }).ToList();
90 103  
91 104 return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
... ... @@ -110,7 +123,11 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
110 123 .Where(x => x.CategoryId == entity.Id)
111 124 .Select(x => x.LocationId)
112 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 133 return dto;
... ... @@ -130,9 +147,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
130 147  
131 148 var displayText = input.DisplayText?.Trim();
132 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 152 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductCategoryDbEntity>()
138 153 .AnyAsync(x => !x.IsDeleted && (x.CategoryCode == code || x.CategoryName == name));
... ... @@ -163,7 +178,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
163 178 };
164 179  
165 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 182 return await GetAsync(entity.Id);
168 183 }
169 184  
... ... @@ -188,9 +203,7 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
188 203  
189 204 var displayText = input.DisplayText?.Trim();
190 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 208 var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductCategoryDbEntity>()
196 209 .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.CategoryCode == code || x.CategoryName == name));
... ... @@ -211,7 +224,8 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
211 224 entity.LastModifierId = CurrentUser?.Id?.ToString();
212 225  
213 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 229 return await GetAsync(id);
216 230 }
217 231  
... ... @@ -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 292 if (availabilityType != "ALL" && availabilityType != "SPECIFIED")
263 293 {
264 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 356 private async Task SaveCategoryLocationsAsync(
... ... @@ -312,6 +386,143 @@ public class ProductCategoryAppService : ApplicationService, IProductCategoryApp
312 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 526 private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total, List<T> items)
316 527 {
317 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 23 {
24 24 private readonly ISqlSugarDbContext _dbContext;
25 25 private readonly ISqlSugarRepository<RoleAggregateRoot, Guid> _roleRepository;
  26 + private readonly ISqlSugarRepository<MenuAggregateRoot, Guid> _menuRepository;
26 27 private readonly ISqlSugarRepository<RoleMenuEntity> _roleMenuRepository;
27 28 private readonly ISqlSugarRepository<RoleDeptEntity> _roleDeptRepository;
28 29 private readonly ISqlSugarRepository<UserRoleEntity> _userRoleRepository;
... ... @@ -30,12 +31,14 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
30 31 public RbacRoleAppService(
31 32 ISqlSugarDbContext dbContext,
32 33 ISqlSugarRepository<RoleAggregateRoot, Guid> roleRepository,
  34 + ISqlSugarRepository<MenuAggregateRoot, Guid> menuRepository,
33 35 ISqlSugarRepository<RoleMenuEntity> roleMenuRepository,
34 36 ISqlSugarRepository<RoleDeptEntity> roleDeptRepository,
35 37 ISqlSugarRepository<UserRoleEntity> userRoleRepository)
36 38 {
37 39 _dbContext = dbContext;
38 40 _roleRepository = roleRepository;
  41 + _menuRepository = menuRepository;
39 42 _roleMenuRepository = roleMenuRepository;
40 43 _roleDeptRepository = roleDeptRepository;
41 44 _userRoleRepository = userRoleRepository;
... ... @@ -75,6 +78,8 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
75 78 AccessPermissionCodes = DeserializeAccessPermissionCodes(x.AccessPermissionCodesJson)
76 79 }).ToList();
77 80  
  81 + await FillAccessPermissionsAsync(items);
  82 +
78 83 var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount;
79 84 var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
80 85 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
... ... @@ -103,7 +108,7 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
103 108 .Select(x => x.MenuId)
104 109 .ToListAsync();
105 110  
106   - return new RbacRoleGetOutputDto
  111 + var dto = new RbacRoleGetOutputDto
107 112 {
108 113 Id = entity.Id,
109 114 RoleName = entity.RoleName ?? string.Empty,
... ... @@ -115,9 +120,12 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
115 120 AccessPermissionCodes = DeserializeAccessPermissionCodes(entity.AccessPermissionCodesJson),
116 121 MenuIds = menuIds
117 122 };
  123 + await FillAccessPermissionsAsync(new List<RbacRoleGetListOutputDto> { dto });
  124 + return dto;
118 125 }
119 126  
120 127 /// <inheritdoc />
  128 + [UnitOfWork]
121 129 public async Task<RbacRoleGetOutputDto> CreateAsync([FromBody] RbacRoleCreateInputVo input)
122 130 {
123 131 var roleName = input.RoleName?.Trim();
... ... @@ -145,17 +153,19 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
145 153 Remark = input.Remark?.Trim(),
146 154 DataScope = (Yi.Framework.Rbac.Domain.Shared.Enums.DataScopeEnum)input.DataScope,
147 155 State = input.State,
148   - OrderNum = input.OrderNum ?? 0,
149   - AccessPermissionCodesJson = SerializeAccessPermissionCodes(ResolveAccessPermissions(input))
  156 + OrderNum = input.OrderNum ?? 0
150 157 };
151 158 EntityHelper.TrySetId(entity, () => GuidGenerator.Create());
152 159  
153 160 await _roleRepository.InsertAsync(entity);
154 161  
  162 + await ApplyRoleMenuBindingsAsync(entity.Id, input);
  163 +
155 164 return await GetAsync(entity.Id);
156 165 }
157 166  
158 167 /// <inheritdoc />
  168 + [UnitOfWork]
159 169 public async Task<RbacRoleGetOutputDto> UpdateAsync(Guid id, [FromBody] RbacRoleUpdateInputVo input)
160 170 {
161 171 var entity = await _roleRepository.GetSingleAsync(x => x.Id == id && x.IsDeleted == false);
... ... @@ -189,15 +199,157 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
189 199 entity.Remark = input.Remark?.Trim();
190 200 entity.DataScope = (Yi.Framework.Rbac.Domain.Shared.Enums.DataScopeEnum)input.DataScope;
191 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 207 await _roleRepository.UpdateAsync(entity);
197 208  
  209 + await ApplyRoleMenuBindingsAsync(entity.Id, input);
  210 +
198 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 353 /// <inheritdoc />
202 354 [UnitOfWork]
203 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 106  
107 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 114 return new TeamMemberGetOutputDto
110 115 {
111 116 Id = user.Id,
... ... @@ -115,6 +120,9 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
115 120 Phone = user.Phone,
116 121 State = user.State,
117 122 RoleId = role?.RoleId,
  123 + PartnerIds = partnerIds,
  124 + RegionIds = regionIds,
  125 + GroupIds = regionIds,
118 126 LocationIds = locationIds,
119 127 AssignedLocations = assigned
120 128 };
... ... @@ -123,10 +131,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
123 131 /// <inheritdoc />
124 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 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 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 155 return await GetAsync(user.Id);
151 156 }
... ... @@ -153,10 +158,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
153 158 /// <inheritdoc />
154 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 163 var user = await _userRepository.GetByIdAsync(id);
162 164 if (user is null || user.IsDeleted)
... ... @@ -187,7 +189,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
187 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 194 return await GetAsync(id);
193 195 }
... ... @@ -584,10 +586,13 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
584 586 return null;
585 587 }).Where(x => x != null).Cast<TeamMemberAssignedLocationDto>().ToList());
586 588  
  589 + var scopeIdsMap = await BuildTeamMemberScopeIdsMapAsync(assignedMap);
  590 +
587 591 return users.Select(u =>
588 592 {
589 593 roleMap.TryGetValue(u.Id, out var role);
590 594 assignedMap.TryGetValue(u.Id.ToString(), out var assigned);
  595 + scopeIdsMap.TryGetValue(u.Id.ToString(), out var scopeIds);
591 596  
592 597 return new TeamMemberGetListOutputDto
593 598 {
... ... @@ -599,11 +604,109 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
599 604 State = u.State,
600 605 RoleId = role?.Id,
601 606 RoleName = role?.RoleName,
  607 + PartnerIds = scopeIds?.PartnerIds ?? new List<string>(),
  608 + RegionIds = scopeIds?.RegionIds ?? new List<string>(),
602 609 AssignedLocations = assigned ?? new List<TeamMemberAssignedLocationDto>()
603 610 };
604 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 710 private async Task UpsertUserLocationsAsync(Guid userId, List<string> locationIds)
608 711 {
609 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 15 using Mapster;
16 16 using Microsoft.AspNetCore.Authorization;
17 17 using Microsoft.AspNetCore.Http;
  18 +using Microsoft.AspNetCore.Mvc;
18 19 using Microsoft.Extensions.Options;
19 20 using Microsoft.IdentityModel.Tokens;
20 21 using SqlSugar;
... ... @@ -25,6 +26,8 @@ using Volo.Abp.Security.Claims;
25 26 using Volo.Abp.Uow;
26 27 using Volo.Abp.Users;
27 28 using Yi.Framework.Core.Helper;
  29 +using Yi.Framework.Rbac.Application.Contracts.Dtos.Account;
  30 +using Yi.Framework.Rbac.Application.Contracts.IServices;
28 31 using Yi.Framework.Rbac.Domain.Entities;
29 32 using Yi.Framework.Rbac.Domain.Managers;
30 33 using Yi.Framework.Rbac.Domain.Shared.Consts;
... ... @@ -47,6 +50,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
47 50 private readonly ICaptcha _captcha;
48 51 private readonly RbacOptions _rbacOptions;
49 52 private readonly JwtOptions _jwtOptions;
  53 + private readonly IForgotPasswordByEmailService _forgotPasswordByEmailService;
50 54  
51 55 public UsAppAuthAppService(
52 56 IAccountManager accountManager,
... ... @@ -55,7 +59,8 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
55 59 IHttpContextAccessor httpContextAccessor,
56 60 ICaptcha captcha,
57 61 IOptions<JwtOptions> jwtOptions,
58   - IOptions<RbacOptions> rbacOptions)
  62 + IOptions<RbacOptions> rbacOptions,
  63 + IForgotPasswordByEmailService forgotPasswordByEmailService)
59 64 {
60 65 _accountManager = accountManager;
61 66 _userRepository = userRepository;
... ... @@ -64,6 +69,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
64 69 _captcha = captcha;
65 70 _jwtOptions = jwtOptions.Value;
66 71 _rbacOptions = rbacOptions.Value;
  72 + _forgotPasswordByEmailService = forgotPasswordByEmailService;
67 73 }
68 74  
69 75 protected ILocalEventBus LocalEventBus => LazyServiceProvider.LazyGetRequiredService<ILocalEventBus>();
... ... @@ -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 146 /// <summary>
128 147 /// 获取当前登录用户已绑定的门店(切换门店时可重新拉取)
129 148 /// </summary>
... ... @@ -147,7 +166,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
147 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 170 /// </remarks>
152 171 /// <param name="locationId">门店主键(Guid 字符串)</param>
153 172 /// <returns>与原型一致的展示字段</returns>
... ... @@ -193,7 +212,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
193 212 LocationName = string.IsNullOrWhiteSpace(loc.LocationName) ? "无" : loc.LocationName.Trim(),
194 213 FullAddress = BuildFullAddress(loc),
195 214 StorePhone = FormatStorePhoneDisplay(loc.Phone),
196   - OperatingHours = "无",
  215 + OperatingHours = string.IsNullOrWhiteSpace(loc.OperatingHours) ? "无" : loc.OperatingHours.Trim(),
197 216 ManagerName = mgrName,
198 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 130 /// Longitude
131 131 /// </summary>
132 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 11 `PartnerName` varchar(256) NOT NULL COMMENT '合作伙伴名称',
12 12 `ContactEmail` varchar(256) DEFAULT NULL COMMENT '联系邮箱',
13 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 19 `State` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
15 20 PRIMARY KEY (`Id`),
16 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 14 public bool State { get; set; }
15 15  
16 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 14 public bool State { get; set; }
15 15  
16 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 37 private readonly IGuidGenerator _guidGenerator;
38 38 private readonly RbacOptions _rbacOptions;
39 39 private readonly IAliyunManger _aliyunManger;
  40 + private readonly IForgotPasswordByEmailService _forgotPasswordByEmailService;
40 41 private IDistributedCache<UserInfoCacheItem, UserInfoCacheKey> _userCache;
41 42 private UserManager _userManager;
42 43 private IHttpContextAccessor _httpContextAccessor;
... ... @@ -51,6 +52,7 @@ namespace Yi.Framework.Rbac.Application.Services
51 52 IGuidGenerator guidGenerator,
52 53 IOptions<RbacOptions> options,
53 54 IAliyunManger aliyunManger,
  55 + IForgotPasswordByEmailService forgotPasswordByEmailService,
54 56 UserManager userManager, IHttpContextAccessor httpContextAccessor)
55 57 {
56 58 _userRepository = userRepository;
... ... @@ -62,6 +64,7 @@ namespace Yi.Framework.Rbac.Application.Services
62 64 _guidGenerator = guidGenerator;
63 65 _rbacOptions = options.Value;
64 66 _aliyunManger = aliyunManger;
  67 + _forgotPasswordByEmailService = forgotPasswordByEmailService;
65 68 _userCache = userCache;
66 69 _userManager = userManager;
67 70 _httpContextAccessor = httpContextAccessor;
... ... @@ -328,6 +331,58 @@ namespace Yi.Framework.Rbac.Application.Services
328 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 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 19 using Yi.Framework.Rbac.Domain.Shared.Consts;
20 20 using Yi.Framework.Rbac.Domain.Shared.Enums;
21 21 using Yi.Framework.SqlSugarCore.Abstractions;
  22 +using System.Linq;
22 23  
23 24 namespace Yi.Framework.Rbac.Application.Services.System
24 25 {
... ... @@ -75,7 +76,17 @@ namespace Yi.Framework.Rbac.Application.Services.System
75 76 .WhereIF(!string.IsNullOrEmpty(input.RoleName), x => x.RoleName.Contains(input.RoleName!))
76 77 .WhereIF(input.State is not null, x => x.State == input.State)
77 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 92 /// <inheritdoc />
... ... @@ -165,9 +176,7 @@ namespace Yi.Framework.Rbac.Application.Services.System
165 176  
166 177 var entity = await MapToEntityAsync(input);
167 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 182 /// <summary>
... ... @@ -191,8 +200,7 @@ namespace Yi.Framework.Rbac.Application.Services.System
191 200  
192 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 221  
214 222 entity.State = state;
215 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 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 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 8  
9 9  
10 10 <ItemGroup>
  11 + <PackageReference Include="MailKit" Version="4.8.0" />
11 12 <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" />
12 13 <PackageReference Include="QuestPDF" Version="2024.12.2" />
13 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 18 Configure<JwtOptions>(configuration.GetSection(nameof(JwtOptions)));
19 19 Configure<RefreshJwtOptions>(configuration.GetSection(nameof(RefreshJwtOptions)));
20 20 Configure<RbacOptions>(configuration.GetSection(nameof(RbacOptions)));
  21 + Configure<ForgotPasswordEmailOptions>(
  22 + configuration.GetSection(ForgotPasswordEmailOptions.SectionName));
21 23 }
22 24 }
23 25 }
24 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 8  
9 9  
10 10 <ItemGroup>
  11 + <!-- 与 FoodLabeling.Application 中 Excel 导出一致;显式引用避免部分发布/拷贝场景下漏带 ClosedXML.dll -->
  12 + <PackageReference Include="ClosedXML" Version="0.102.3" />
11 13 <PackageReference Include="Hangfire.MemoryStorage" Version="1.8.1.1" />
12 14 <PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.4" />
13 15 <!-- 解决 docker 构建冲突问题,冲突版本信息如下 -->
... ... @@ -67,4 +69,11 @@
67 69 </None>
68 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 79 </Project>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json
... ... @@ -18,10 +18,10 @@
18 18 "Microsoft.AspNetCore": "Warning"
19 19 }
20 20 },
21   - //应用启动
  21 + //应用启动:SelfUrl 供 Program.cs UseUrls 绑定;用 0.0.0.0 避免写成固定局域网 IP 在本机无该网卡时启动失败(WinError 10049)
22 22 "App": {
23 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 27 "Settings": {
... ... @@ -104,11 +104,39 @@
104 104 //开启定时数据库备份
105 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 124 "SemanticKernel": {
110 125 "ModelIds": ["gpt-4o"],
111 126 "Endpoint": "https://xxx.com/v1",
112 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 1819 }
1820 1820 setSubmitting(true);
1821 1821 try {
1822   - const payload = {
  1822 + const payload: RbacRoleUpsertInput = {
1823 1823 roleName: roleName.trim(),
1824 1824 roleCode: roleCode.trim(),
1825 1825 remark: remark.trim() ? remark.trim() : null,
... ...
美国版/Food Labeling Management Platform/src/types/authSession.ts
... ... @@ -27,5 +27,11 @@ export type CurrentUserMenuPermissionsOutputDto = {
27 27 roleCodes: string[];
28 28 permissionCodes: string[];
29 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 224  
225 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 229 ### 3.1 下载批量导入模板
228 230  
229 231 | 项目 | 说明 |
230 232 |------|------|
231 233 | 方法 | `DownloadProductImportTemplateAsync` |
232   -| HTTP | `GET` |
  234 +| HTTP | `GET`(已标 `[HttpGet]`,与约定 `Download*` 可能判为 `POST` 的情况区分) |
233 235 | 常见路径 | `/api/app/product/download-product-import-template` |
234 236 | 作用 | 从 `TemplateDirectory` 读取 `ProductTemplateFileName` 指向的 xlsx 并下载(工作表名一般为 `Products`) |
235 237  
... ... @@ -238,14 +240,17 @@
238 240 | 项目 | 说明 |
239 241 |------|------|
240 242 | 方法 | `ExportProductsExcelAsync` |
241   -| HTTP | `GET` |
  243 +| HTTP | `GET`(应用服务方法上已标 `[HttpGet]`;ABP 对 `Export*` 默认易判成 `POST`,用 GET 否则会 **405**) |
242 244 | 常见路径 | `/api/app/product/export-products-excel` |
  245 +| Postman | **不要** 填 Body;筛选用 **Params**(Query)或拼在 URL 上;导出**不需要**上传 `file`(上传文件是 **3.3 导入**) |
243 246 | Query | 与产品列表筛选一致:`Keyword`、`State`、`Sorting` |
244 247 | 数据范围 | **全量**:符合筛选条件的全部产品;**不使用** `SkipCount` / `MaxResultCount` |
245 248 | 排序 | 有 `Sorting` 则按其排序,否则默认按 `ProductName` 降序 |
246 249 | 响应文件名示例 | `products-export-yyyyMMdd-HHmmss.xlsx` |
247 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 254 ### 3.3 批量导入 Excel
250 255  
251 256 | 项目 | 说明 |
... ... @@ -289,7 +294,7 @@
289 294  
290 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 299 ### 5.1 Company(合作伙伴 / `PartnerAppService`)
295 300  
... ...