Commit 28fa41e9ff6f3f7ddc78651c9e0123b24d7bd3b6

Authored by 杨鑫
2 parents 4cb354d4 14afbc16

合并

Showing 48 changed files with 2679 additions and 380 deletions
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/RbacAccessPermissionHelper.cs
  1 +using System.Text.Json;
  2 +
1 3 namespace FoodLabeling.Application.Helpers;
2 4  
3 5 /// <summary>
... ... @@ -45,11 +47,37 @@ public static class RbacAccessPermissionHelper
45 47 }
46 48  
47 49 /// <summary>
48   - /// 解析 accessPermissions 入参(英文逗号分隔,忽略大小写)
  50 + /// 解析 accessPermissions 入参:支持 JSON 数组字符串(如 <c>["manage_labels"]</c>)、英文逗号分隔
49 51 /// </summary>
50 52 public static List<string> ParseAccessPermissionCodes(string accessPermissions)
51 53 {
52   - return accessPermissions
  54 + var raw = accessPermissions?.Trim();
  55 + if (string.IsNullOrWhiteSpace(raw))
  56 + {
  57 + return new List<string>();
  58 + }
  59 +
  60 + if (raw.StartsWith('['))
  61 + {
  62 + try
  63 + {
  64 + var fromJson = JsonSerializer.Deserialize<List<string>>(raw);
  65 + if (fromJson is { Count: > 0 })
  66 + {
  67 + return fromJson
  68 + .Where(c => !string.IsNullOrWhiteSpace(c))
  69 + .Select(c => c.Trim())
  70 + .Distinct(StringComparer.OrdinalIgnoreCase)
  71 + .ToList();
  72 + }
  73 + }
  74 + catch
  75 + {
  76 + // fall through to delimiter split
  77 + }
  78 + }
  79 +
  80 + return raw
53 81 .Split(new[] { ',', ';', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
54 82 .Where(c => !string.IsNullOrWhiteSpace(c))
55 83 .Select(c => c.Trim())
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Dashboard/DashboardRecentLabelItemDto.cs
... ... @@ -8,7 +8,9 @@ public class DashboardRecentLabelItemDto
8 8 /// <summary>打印任务 Id(fl_label_print_task.Id)</summary>
9 9 public string TaskId { get; set; } = string.Empty;
10 10  
11   - /// <summary>标签编码(界面 Serial / Label ID,如 1-251201)</summary>
  11 + /// <summary>
  12 + /// 展示用 Label ID:门店当日打印序号 <c>yyyyMMdd-n</c>(与 preview / print-log 一致;非 <c>fl_label.LabelCode</c>)
  13 + /// </summary>
12 14 public string LabelCode { get; set; } = string.Empty;
13 15  
14 16 /// <summary>展示名称:优先产品名,否则标签名称</summary>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelTemplate/LabelTemplateGetListInputVo.cs
... ... @@ -13,7 +13,12 @@ public class LabelTemplateGetListInputVo : PagedAndSortedResultRequestDto
13 13 public string? Keyword { get; set; }
14 14  
15 15 /// <summary>
16   - /// 按 Region 筛选(<c>fl_group.Id</c>):返回适用于该 Region 下任一门门店的模板,以及 <c>appliedLocation=ALL</c> 的模板
  16 + /// 按 Company 筛选(<c>fl_partner.Id</c>);与 <see cref="GroupId"/>、<see cref="LocationId"/> 按「门店优先」解析
  17 + /// </summary>
  18 + public string? PartnerId { get; set; }
  19 +
  20 + /// <summary>
  21 + /// 按 Region 筛选(<c>fl_group.Id</c>):返回适用于该 Region 下任一门门店的模板,以及全范围模板
17 22 /// </summary>
18 23 public string? GroupId { get; set; }
19 24  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleCreateInputVo.cs
... ... @@ -29,7 +29,7 @@ public class RbacRoleCreateInputVo
29 29 public List<Guid>? MenuIds { get; set; }
30 30  
31 31 /// <summary>
32   - /// 按 PermissionCode 绑定菜单(英文逗号分隔);传空字符串表示清空绑定;不传则不修改已有绑定(仅编辑时)
  32 + /// 按 PermissionCode 绑定菜单:JSON 数组字符串(如 <c>["manage_labels"]</c>)、英文逗号分隔;传空字符串表示清空绑定;不传则不修改已有绑定(仅编辑时)
33 33 /// </summary>
34 34 public string? AccessPermissions { get; set; }
35 35  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs
... ... @@ -28,7 +28,7 @@ public class TeamMemberCreateInputVo
28 28 public List<string>? PartnerIds { get; set; }
29 29  
30 30 /// <summary>
31   - /// 适用 Region 多选(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并
  31 + /// 适用 Region 多选(<c>fl_group.Id</c>);Company Admin 仅传 Company 时可省略,后端自动绑定该公司下全部 Region 与门店
32 32 /// </summary>
33 33 public List<string>? RegionIds { get; set; }
34 34  
... ... @@ -38,7 +38,7 @@ public class TeamMemberCreateInputVo
38 38 public List<string>? GroupIds { get; set; }
39 39  
40 40 /// <summary>
41   - /// 适用门店多选(<c>location.Id</c>);与 Company/Region 合并后写入 <c>userlocation</c>
  41 + /// 适用门店多选(<c>location.Id</c>);Company Admin 仅传 Company 时可省略,与 Region 合并后写入 <c>userlocation</c>
42 42 /// </summary>
43 43 public List<string>? LocationIds { get; set; }
44 44  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetListOutputDto.cs
... ... @@ -27,6 +27,9 @@ public class TeamMemberGetListOutputDto
27 27 /// <summary>适用 Region Id 列表(多选)</summary>
28 28 public List<string> RegionIds { get; set; } = new();
29 29  
  30 + /// <summary>已绑定门店 Id 列表(<c>location.Id</c>)</summary>
  31 + public List<string> LocationIds { get; set; } = new();
  32 +
30 33 public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new();
31 34 }
32 35  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs
... ... @@ -28,7 +28,7 @@ public class TeamMemberUpdateInputVo
28 28 public List<string>? PartnerIds { get; set; }
29 29  
30 30 /// <summary>
31   - /// 适用 Region 多选(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并
  31 + /// 适用 Region 多选(<c>fl_group.Id</c>);Company Admin 仅传 Company 时可省略
32 32 /// </summary>
33 33 public List<string>? RegionIds { get; set; }
34 34  
... ... @@ -38,7 +38,7 @@ public class TeamMemberUpdateInputVo
38 38 public List<string>? GroupIds { get; set; }
39 39  
40 40 /// <summary>
41   - /// 适用门店多选(<c>location.Id</c>);与 Company/Region 合并后写入 <c>userlocation</c>
  41 + /// 适用门店多选(<c>location.Id</c>);Company Admin 仅传 Company 时可省略
42 42 /// </summary>
43 43 public List<string>? LocationIds { get; set; }
44 44  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/AuthScopeCompanyOptionDto.cs deleted
1   -namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
2   -
3   -/// <summary>auth-scope / us-app-auth 公司下拉项</summary>
4   -public class AuthScopeCompanyOptionDto
5   -{
6   - public string Id { get; set; } = string.Empty;
7   -
8   - public string PartnerName { get; set; } = string.Empty;
9   -
10   - public bool State { get; set; } = true;
11   -}
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/AuthScopeLocationOptionDto.cs deleted
1   -namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
2   -
3   -/// <summary>auth-scope / us-app-auth 门店下拉项</summary>
4   -public class AuthScopeLocationOptionDto
5   -{
6   - public string Id { get; set; } = string.Empty;
7   -
8   - public string LocationCode { get; set; } = string.Empty;
9   -
10   - public string LocationName { get; set; } = string.Empty;
11   -
12   - public string FullAddress { get; set; } = string.Empty;
13   -
14   - public bool State { get; set; } = true;
15   -
16   - public string PartnerId { get; set; } = string.Empty;
17   -
18   - public string GroupId { get; set; } = string.Empty;
19   -
20   - public string GroupName { get; set; } = string.Empty;
21   -}
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/AuthScopeRegionOptionDto.cs deleted
1   -namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
2   -
3   -/// <summary>auth-scope / us-app-auth Region 下拉项</summary>
4   -public class AuthScopeRegionOptionDto
5   -{
6   - public string Id { get; set; } = string.Empty;
7   -
8   - public string GroupName { get; set; } = string.Empty;
9   -
10   - public string PartnerId { get; set; } = string.Empty;
11   -
12   - public bool State { get; set; } = true;
13   -}
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/AuthScopeSelectLocationOutputDto.cs deleted
1   -namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
2   -
3   -/// <summary>确认选店返回</summary>
4   -public class AuthScopeSelectLocationOutputDto
5   -{
6   - public string PartnerId { get; set; } = string.Empty;
7   -
8   - public string PartnerName { get; set; } = string.Empty;
9   -
10   - public string GroupId { get; set; } = string.Empty;
11   -
12   - public string GroupName { get; set; } = string.Empty;
13   -
14   - public UsAppBoundLocationDto Location { get; set; } = new();
15   -}
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/PrintLogGetListInputVo.cs
... ... @@ -11,5 +11,15 @@ public class PrintLogGetListInputVo : PagedAndSortedResultRequestDto
11 11 /// 当前门店 Id(location.Id,Guid 字符串)
12 12 /// </summary>
13 13 public string LocationId { get; set; } = string.Empty;
14   -}
15 14  
  15 + /// <summary>
  16 + /// 打印日期(自然日);按 <c>PrintedAt ?? CreationTime</c> 筛选该日记录。
  17 + /// 未传且未传 <see cref="PrintDateDay"/> 时不按日过滤(返回该门店全部打印记录,分页)。
  18 + /// </summary>
  19 + public DateTime? PrintDate { get; set; }
  20 +
  21 + /// <summary>
  22 + /// 打印日期字符串(<c>yyyy-MM-dd</c>);优先于 <see cref="PrintDate"/>,避免 JSON 仅日期时的 UTC 歧义。
  23 + /// </summary>
  24 + public string? PrintDateDay { get; set; }
  25 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IDashboardAppService.cs
... ... @@ -14,6 +14,7 @@ public interface IDashboardAppService : IApplicationService
14 14 /// <remarks>
15 15 /// 按 Token 数据范围统计:<b>系统管理员</b>为全平台;<b>其它账号</b>仅统计其 <c>userlocation</c> 绑定门店所属公司范围内数据。
16 16 /// <c>activeUsers</c> / <c>people</c> 仅统计有门店绑定的成员,且<b>不包含</b>内置系统 admin(用户名 <c>admin</c> 或角色码 <c>admin</c>)。
  17 + /// <c>recentLabels[].labelCode</c> 为门店当日打印序号 <c>yyyyMMdd-n</c>,规则与 <c>POST /api/app/us-app-labeling/preview</c>、print-log 一致。
17 18 /// </remarks>
18 19 Task<DashboardOverviewOutputDto> GetOverviewAsync();
19 20 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILabelTemplateAppService.cs
... ... @@ -11,8 +11,9 @@ namespace FoodLabeling.Application.Contracts.IServices;
11 11 public interface ILabelTemplateAppService : IApplicationService
12 12 {
13 13 /// <summary>
14   - /// 标签模板分页列表;Query 支持 <c>groupId</c>(Region)、<c>locationId</c>(门店)筛选;
15   - /// 出参含 <c>region</c>/<c>location</c>、<c>items</c>/<c>itemNames</c>(模板控件名称,逗号拼接)。
  14 + /// 标签模板分页列表;Query 支持 <c>partnerId</c>(Company)、<c>groupId</c>(Region)、<c>locationId</c>(门店)筛选;
  15 + /// 出参含 <c>company</c>/<c>region</c>/<c>location</c>、<c>items</c>/<c>itemNames</c>(模板控件名称,逗号拼接)。
  16 + /// <c>SkipCount</c> 为页码(从 1 起,第一页传 1)。
16 17 /// </summary>
17 18 Task<PagedResultWithPageDto<LabelTemplateGetListOutputDto>> GetListAsync(LabelTemplateGetListInputVo input);
18 19  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs
... ... @@ -92,16 +92,4 @@ public interface IUsAppAuthAppService : IApplicationService
92 92 /// <response code="400">Invalid code or password policy.</response>
93 93 /// <response code="500">Server error</response>
94 94 Task<string> PostResetPasswordByEmailAsync(RetrievePasswordByEmailDto input);
95   -
96   - /// <summary>App 管理员:可选公司列表</summary>
97   - Task<List<AuthScopeCompanyOptionDto>> GetAdminScopeCompaniesAsync();
98   -
99   - /// <summary>App 管理员:指定公司下 Region</summary>
100   - Task<List<AuthScopeRegionOptionDto>> GetAdminScopeRegionsAsync(string partnerId);
101   -
102   - /// <summary>App 管理员:指定公司与 Region 下门店</summary>
103   - Task<List<AuthScopeLocationOptionDto>> GetAdminScopeLocationsAsync(string partnerId, string groupId);
104   -
105   - /// <summary>App 管理员:确认当前工作门店</summary>
106   - Task<AuthScopeSelectLocationOutputDto> SelectAdminScopeLocationAsync(UsAppSelectAdminScopeLocationInputVo input);
107 95 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppLabelingAppService.cs
... ... @@ -33,7 +33,7 @@ public interface IUsAppLabelingAppService : IApplicationService
33 33  
34 34 /// <summary>
35 35 /// App 打印日志:当前门店打印记录(分页)。管理员 / Partner 角色可见门店内全部;其它角色仅本人。
36   - /// 出参 <c>labelId</c> 为门店当日打印序号(<c>yyyyMMdd-n</c>);<c>labelEntityId</c> 为 <c>fl_label.Id</c>;<c>labelCode</c> 为标签编码
  36 + /// 支持 <c>printDate</c> 按自然日筛选;出参 <c>labelId</c> 为门店当日打印序号(<c>yyyyMMdd-n</c>)
37 37 /// </summary>
38 38 Task<PagedResultWithPageDto<PrintLogItemDto>> GetPrintLogListAsync(PrintLogGetListInputVo input);
39 39  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateListItemsHelper.cs
... ... @@ -29,7 +29,8 @@ public static class LabelTemplateListItemsHelper
29 29 ElementName = x.ElementName,
30 30 TypeAdd = x.TypeAdd,
31 31 ElementType = x.ElementType,
32   - OrderNum = x.OrderNum
  32 + OrderNum = x.OrderNum,
  33 + ElementKey = x.ElementKey
33 34 })
34 35 .ToListAsync();
35 36  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateQueryHelper.cs 0 → 100644
  1 +using FoodLabeling.Application.Services.DbModels;
  2 +using SqlSugar;
  3 +
  4 +namespace FoodLabeling.Application.Helpers;
  5 +
  6 +/// <summary>
  7 +/// 标签模板查询列投影:仅映射库内真实列,避免 ORM 元数据缓存或旧实体仍 SELECT 不存在的 scope 列。
  8 +/// </summary>
  9 +public static class LabelTemplateQueryHelper
  10 +{
  11 + /// <summary>
  12 + /// 列表/详情/引用校验等只读场景:强制 SQL 不含 <c>AppliedPartnerType</c> / <c>AppliedRegionType</c>。
  13 + /// </summary>
  14 + public static ISugarQueryable<FlLabelTemplateDbEntity> ProjectListColumns(
  15 + ISugarQueryable<FlLabelTemplateDbEntity> query) =>
  16 + query.Select(x => new FlLabelTemplateDbEntity
  17 + {
  18 + Id = x.Id,
  19 + IsDeleted = x.IsDeleted,
  20 + CreationTime = x.CreationTime,
  21 + CreatorId = x.CreatorId,
  22 + LastModifierId = x.LastModifierId,
  23 + LastModificationTime = x.LastModificationTime,
  24 + ConcurrencyStamp = x.ConcurrencyStamp,
  25 + TemplateCode = x.TemplateCode,
  26 + TemplateName = x.TemplateName,
  27 + LabelType = x.LabelType,
  28 + Unit = x.Unit,
  29 + Width = x.Width,
  30 + Height = x.Height,
  31 + AppliedLocationType = x.AppliedLocationType,
  32 + ShowRuler = x.ShowRuler,
  33 + ShowGrid = x.ShowGrid,
  34 + VersionNo = x.VersionNo,
  35 + State = x.State
  36 + });
  37 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs
... ... @@ -150,17 +150,24 @@ public static class LabelTemplateScopeHelper
150 150 string? currentUserId,
151 151 DateTime now)
152 152 {
153   - await db.Deleteable<FlLabelTemplatePartnerDbEntity>()
154   - .Where(x => x.TemplateId == templateId)
155   - .ExecuteCommandAsync();
156   - await db.Deleteable<FlLabelTemplateRegionDbEntity>()
157   - .Where(x => x.TemplateId == templateId)
158   - .ExecuteCommandAsync();
  153 + var hasExtendedScope = await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(db);
  154 +
  155 + if (hasExtendedScope)
  156 + {
  157 + await db.Deleteable<FlLabelTemplatePartnerDbEntity>()
  158 + .Where(x => x.TemplateId == templateId)
  159 + .ExecuteCommandAsync();
  160 + await db.Deleteable<FlLabelTemplateRegionDbEntity>()
  161 + .Where(x => x.TemplateId == templateId)
  162 + .ExecuteCommandAsync();
  163 + }
  164 +
159 165 await db.Deleteable<FlLabelTemplateLocationDbEntity>()
160 166 .Where(x => x.TemplateId == templateId)
161 167 .ExecuteCommandAsync();
162 168  
163   - if (string.Equals(scope.AppliedPartnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
  169 + if (hasExtendedScope
  170 + && string.Equals(scope.AppliedPartnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
164 171 && scope.PartnerIds.Count > 0)
165 172 {
166 173 var rows = scope.PartnerIds.Select(pid => new FlLabelTemplatePartnerDbEntity
... ... @@ -174,7 +181,8 @@ public static class LabelTemplateScopeHelper
174 181 await db.Insertable(rows).ExecuteCommandAsync();
175 182 }
176 183  
177   - if (string.Equals(scope.AppliedRegionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
  184 + if (hasExtendedScope
  185 + && string.Equals(scope.AppliedRegionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
178 186 && scope.RegionIds.Count > 0)
179 187 {
180 188 var rows = scope.RegionIds.Select(gid => new FlLabelTemplateRegionDbEntity
... ... @@ -216,12 +224,18 @@ public static class LabelTemplateScopeHelper
216 224  
217 225 var templateIds = templates.Select(x => x.Id).Distinct(StringComparer.Ordinal).ToList();
218 226  
219   - var partnerLinks = await db.Queryable<FlLabelTemplatePartnerDbEntity>()
220   - .Where(x => templateIds.Contains(x.TemplateId))
221   - .ToListAsync();
222   - var regionLinks = await db.Queryable<FlLabelTemplateRegionDbEntity>()
223   - .Where(x => templateIds.Contains(x.TemplateId))
224   - .ToListAsync();
  227 + var hasExtendedScope = await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(db);
  228 +
  229 + var partnerLinks = hasExtendedScope
  230 + ? await db.Queryable<FlLabelTemplatePartnerDbEntity>()
  231 + .Where(x => templateIds.Contains(x.TemplateId))
  232 + .ToListAsync()
  233 + : new List<FlLabelTemplatePartnerDbEntity>();
  234 + var regionLinks = hasExtendedScope
  235 + ? await db.Queryable<FlLabelTemplateRegionDbEntity>()
  236 + .Where(x => templateIds.Contains(x.TemplateId))
  237 + .ToListAsync()
  238 + : new List<FlLabelTemplateRegionDbEntity>();
225 239 var locationLinks = await db.Queryable<FlLabelTemplateLocationDbEntity>()
226 240 .Where(x => templateIds.Contains(x.TemplateId))
227 241 .ToListAsync();
... ... @@ -274,8 +288,6 @@ public static class LabelTemplateScopeHelper
274 288  
275 289 foreach (var template in templates)
276 290 {
277   - var partnerType = NormalizeScopeType(template.AppliedPartnerType, ScopeAll);
278   - var regionType = NormalizeScopeType(template.AppliedRegionType, ScopeAll);
279 291 var locationType = NormalizeScopeType(template.AppliedLocationType, ScopeAll);
280 292  
281 293 var pIds = LocationScopeBindingHelper.NormalizeIds(
... ... @@ -285,34 +297,39 @@ public static class LabelTemplateScopeHelper
285 297 var lIds = LocationScopeBindingHelper.NormalizeIds(
286 298 locationLinks.Where(x => x.TemplateId == template.Id).Select(x => x.LocationId).ToList());
287 299  
288   - if (pIds.Count == 0
289   - && string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
  300 + var partnerType = pIds.Count > 0 ? ScopeSpecified : ScopeAll;
  301 + var regionType = rIds.Count > 0 ? ScopeSpecified : ScopeAll;
  302 +
  303 + if (hasExtendedScope
  304 + && pIds.Count == 0
290 305 && lIds.Count > 0)
291 306 {
292 307 pIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, lIds);
  308 + if (pIds.Count > 0)
  309 + {
  310 + partnerType = ScopeSpecified;
  311 + }
293 312 }
294 313  
295   - if (rIds.Count == 0
296   - && string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
  314 + if (hasExtendedScope
  315 + && rIds.Count == 0
297 316 && lIds.Count > 0)
298 317 {
299 318 rIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(db, lIds);
300   - }
301   -
302   - if (pIds.Count == 0 && !string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))
303   - {
304   - partnerType = ScopeAll;
305   - }
306   -
307   - if (rIds.Count == 0 && !string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))
308   - {
309   - regionType = ScopeAll;
  319 + if (rIds.Count > 0)
  320 + {
  321 + regionType = ScopeSpecified;
  322 + }
310 323 }
311 324  
312 325 if (lIds.Count == 0 && !string.Equals(locationType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))
313 326 {
314 327 locationType = ScopeAll;
315 328 }
  329 + else if (lIds.Count > 0)
  330 + {
  331 + locationType = ScopeSpecified;
  332 + }
316 333  
317 334 var companyDisplay = string.Equals(partnerType, ScopeAll, StringComparison.OrdinalIgnoreCase)
318 335 ? AllCompaniesDisplay
... ... @@ -354,12 +371,36 @@ public static class LabelTemplateScopeHelper
354 371 return query;
355 372 }
356 373  
  374 + var hasExtendedScope = await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(db);
  375 +
357 376 if (scopedLocationIds.Count == 0)
358 377 {
  378 + return hasExtendedScope
  379 + ? query.Where(t =>
  380 + t.AppliedLocationType == ScopeAll
  381 + && !SqlFunc.Subqueryable<FlLabelTemplateLocationDbEntity>()
  382 + .Where(l => l.TemplateId == t.Id)
  383 + .Any()
  384 + && !SqlFunc.Subqueryable<FlLabelTemplatePartnerDbEntity>()
  385 + .Where(p => p.TemplateId == t.Id)
  386 + .Any()
  387 + && !SqlFunc.Subqueryable<FlLabelTemplateRegionDbEntity>()
  388 + .Where(r => r.TemplateId == t.Id)
  389 + .Any())
  390 + : query.Where(t =>
  391 + t.AppliedLocationType == ScopeAll
  392 + && !SqlFunc.Subqueryable<FlLabelTemplateLocationDbEntity>()
  393 + .Where(l => l.TemplateId == t.Id)
  394 + .Any());
  395 + }
  396 +
  397 + if (!hasExtendedScope)
  398 + {
359 399 return query.Where(t =>
360   - t.AppliedPartnerType == ScopeAll
361   - && t.AppliedRegionType == ScopeAll
362   - && t.AppliedLocationType == ScopeAll);
  400 + t.AppliedLocationType == ScopeAll
  401 + || SqlFunc.Subqueryable<FlLabelTemplateLocationDbEntity>()
  402 + .Where(l => l.TemplateId == t.Id && scopedLocationIds.Contains(l.LocationId))
  403 + .Any());
363 404 }
364 405  
365 406 var scopedPartnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
... ... @@ -368,13 +409,28 @@ public static class LabelTemplateScopeHelper
368 409 db, scopedLocationIds);
369 410  
370 411 return query.Where(t =>
371   - (t.AppliedPartnerType == ScopeAll && t.AppliedRegionType == ScopeAll && t.AppliedLocationType == ScopeAll)
  412 + (
  413 + t.AppliedLocationType == ScopeAll
  414 + && !SqlFunc.Subqueryable<FlLabelTemplateLocationDbEntity>()
  415 + .Where(l => l.TemplateId == t.Id)
  416 + .Any()
  417 + && !SqlFunc.Subqueryable<FlLabelTemplatePartnerDbEntity>()
  418 + .Where(p => p.TemplateId == t.Id)
  419 + .Any()
  420 + && !SqlFunc.Subqueryable<FlLabelTemplateRegionDbEntity>()
  421 + .Where(r => r.TemplateId == t.Id)
  422 + .Any()
  423 + )
372 424 || (
373   - (t.AppliedPartnerType == ScopeAll
  425 + (!SqlFunc.Subqueryable<FlLabelTemplatePartnerDbEntity>()
  426 + .Where(p => p.TemplateId == t.Id)
  427 + .Any()
374 428 || SqlFunc.Subqueryable<FlLabelTemplatePartnerDbEntity>()
375 429 .Where(p => p.TemplateId == t.Id && scopedPartnerIds.Contains(p.PartnerId))
376 430 .Any())
377   - && (t.AppliedRegionType == ScopeAll
  431 + && (!SqlFunc.Subqueryable<FlLabelTemplateRegionDbEntity>()
  432 + .Where(r => r.TemplateId == t.Id)
  433 + .Any()
378 434 || SqlFunc.Subqueryable<FlLabelTemplateRegionDbEntity>()
379 435 .Where(r => r.TemplateId == t.Id && scopedGroupIds.Contains(r.GroupId))
380 436 .Any())
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeSchemaHelper.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Helpers;
  4 +
  5 +/// <summary>
  6 +/// 探测标签模板 Company/Region scope 列与关联表是否已迁移(<c>fl_label_template_scope.sql</c>)。
  7 +/// 未迁移时 ORM 实体不含 <c>AppliedPartnerType</c>/<c>AppliedRegionType</c>,读写走 raw SQL 或关联表推断。
  8 +/// </summary>
  9 +public static class LabelTemplateScopeSchemaHelper
  10 +{
  11 + private static LabelTemplateScopeSchemaStatus? _cached;
  12 +
  13 + public sealed class LabelTemplateScopeSchemaStatus
  14 + {
  15 + public bool HasAppliedPartnerTypeColumn { get; init; }
  16 +
  17 + public bool HasAppliedRegionTypeColumn { get; init; }
  18 +
  19 + public bool HasPartnerScopeTable { get; init; }
  20 +
  21 + public bool HasRegionScopeTable { get; init; }
  22 +
  23 + /// <summary>partner/region 关联表均已创建(用于多选明细)。</summary>
  24 + public bool HasPartnerRegionScopeTables => HasPartnerScopeTable && HasRegionScopeTable;
  25 +
  26 + /// <summary>列 + 关联表均已迁移。</summary>
  27 + public bool IsFullSchema =>
  28 + HasAppliedPartnerTypeColumn
  29 + && HasAppliedRegionTypeColumn
  30 + && HasPartnerRegionScopeTables;
  31 + }
  32 +
  33 + public static async Task<LabelTemplateScopeSchemaStatus> GetStatusAsync(ISqlSugarClient db)
  34 + {
  35 + if (_cached is not null)
  36 + {
  37 + return _cached;
  38 + }
  39 +
  40 + try
  41 + {
  42 + var partnerColCount = await db.Ado.GetIntAsync(
  43 + """
  44 + SELECT COUNT(*)
  45 + FROM information_schema.COLUMNS
  46 + WHERE TABLE_SCHEMA = DATABASE()
  47 + AND TABLE_NAME = 'fl_label_template'
  48 + AND COLUMN_NAME = 'AppliedPartnerType'
  49 + """);
  50 +
  51 + var regionColCount = await db.Ado.GetIntAsync(
  52 + """
  53 + SELECT COUNT(*)
  54 + FROM information_schema.COLUMNS
  55 + WHERE TABLE_SCHEMA = DATABASE()
  56 + AND TABLE_NAME = 'fl_label_template'
  57 + AND COLUMN_NAME = 'AppliedRegionType'
  58 + """);
  59 +
  60 + var partnerTableCount = await db.Ado.GetIntAsync(
  61 + """
  62 + SELECT COUNT(*)
  63 + FROM information_schema.TABLES
  64 + WHERE TABLE_SCHEMA = DATABASE()
  65 + AND TABLE_NAME = 'fl_label_template_partner'
  66 + """);
  67 +
  68 + var regionTableCount = await db.Ado.GetIntAsync(
  69 + """
  70 + SELECT COUNT(*)
  71 + FROM information_schema.TABLES
  72 + WHERE TABLE_SCHEMA = DATABASE()
  73 + AND TABLE_NAME = 'fl_label_template_region'
  74 + """);
  75 +
  76 + _cached = new LabelTemplateScopeSchemaStatus
  77 + {
  78 + HasAppliedPartnerTypeColumn = partnerColCount > 0,
  79 + HasAppliedRegionTypeColumn = regionColCount > 0,
  80 + HasPartnerScopeTable = partnerTableCount > 0,
  81 + HasRegionScopeTable = regionTableCount > 0
  82 + };
  83 + }
  84 + catch
  85 + {
  86 + _cached = new LabelTemplateScopeSchemaStatus();
  87 + }
  88 +
  89 + return _cached;
  90 + }
  91 +
  92 + /// <summary>单元测试或切换库后调用。</summary>
  93 + public static void ResetCacheForTests() => _cached = null;
  94 +
  95 + /// <summary>
  96 + /// 是否已创建 <c>fl_label_template_partner</c> 与 <c>fl_label_template_region</c>。
  97 + /// </summary>
  98 + public static async Task<bool> HasPartnerRegionScopeTablesAsync(ISqlSugarClient db)
  99 + {
  100 + var status = await GetStatusAsync(db);
  101 + return status.HasPartnerRegionScopeTables;
  102 + }
  103 +
  104 + /// <summary>
  105 + /// 已迁移 scope 列时,写入 <c>AppliedPartnerType</c> / <c>AppliedRegionType</c>(未迁移则 no-op)。
  106 + /// </summary>
  107 + public static async Task SetAppliedScopeTypesAsync(
  108 + ISqlSugarClient db,
  109 + string templateId,
  110 + string appliedPartnerType,
  111 + string appliedRegionType)
  112 + {
  113 + if (string.IsNullOrWhiteSpace(templateId))
  114 + {
  115 + return;
  116 + }
  117 +
  118 + var status = await GetStatusAsync(db);
  119 + if (!status.HasAppliedPartnerTypeColumn && !status.HasAppliedRegionTypeColumn)
  120 + {
  121 + return;
  122 + }
  123 +
  124 + if (status.HasAppliedPartnerTypeColumn && status.HasAppliedRegionTypeColumn)
  125 + {
  126 + await db.Ado.ExecuteCommandAsync(
  127 + """
  128 + UPDATE fl_label_template
  129 + SET AppliedPartnerType = @partnerType,
  130 + AppliedRegionType = @regionType
  131 + WHERE Id = @id
  132 + """,
  133 + new
  134 + {
  135 + partnerType = appliedPartnerType.Trim(),
  136 + regionType = appliedRegionType.Trim(),
  137 + id = templateId.Trim()
  138 + });
  139 + return;
  140 + }
  141 +
  142 + if (status.HasAppliedPartnerTypeColumn)
  143 + {
  144 + await db.Ado.ExecuteCommandAsync(
  145 + "UPDATE fl_label_template SET AppliedPartnerType = @type WHERE Id = @id",
  146 + new { type = appliedPartnerType.Trim(), id = templateId.Trim() });
  147 + }
  148 +
  149 + if (status.HasAppliedRegionTypeColumn)
  150 + {
  151 + await db.Ado.ExecuteCommandAsync(
  152 + "UPDATE fl_label_template SET AppliedRegionType = @type WHERE Id = @id",
  153 + new { type = appliedRegionType.Trim(), id = templateId.Trim() });
  154 + }
  155 + }
  156 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs
... ... @@ -2,6 +2,8 @@ using FoodLabeling.Application.Services.DbModels;
2 2 using FoodLabeling.Domain.Entities;
3 3 using SqlSugar;
4 4 using Volo.Abp;
  5 +using Volo.Abp.Users;
  6 +using Yi.Framework.Rbac.Domain.Entities;
5 7 using Yi.Framework.SqlSugarCore.Abstractions;
6 8  
7 9 namespace FoodLabeling.Application.Helpers;
... ... @@ -12,6 +14,87 @@ namespace FoodLabeling.Application.Helpers;
12 14 public static class LocationScopeBindingHelper
13 15 {
14 16 /// <summary>
  17 + /// 读取用户 <c>userlocation</c> 绑定的门店 Id(Guid 归一化,避免大小写导致漏绑)。
  18 + /// </summary>
  19 + public static async Task<List<string>> ResolveBoundLocationIdsForUserAsync(
  20 + ISqlSugarClient db,
  21 + Guid userId)
  22 + {
  23 + var userKey = TeamMemberListScopeHelper.UserKey(userId);
  24 + var links = await db.Queryable<UserLocationDbEntity>()
  25 + .Where(x => !x.IsDeleted)
  26 + .ToListAsync();
  27 +
  28 + var locationIds = new HashSet<string>(StringComparer.Ordinal);
  29 + foreach (var link in links)
  30 + {
  31 + if (TeamMemberListScopeHelper.NormalizeScopeKey(link.UserId) != userKey)
  32 + {
  33 + continue;
  34 + }
  35 +
  36 + var locKey = TeamMemberListScopeHelper.NormalizeScopeKey(link.LocationId);
  37 + if (string.IsNullOrEmpty(locKey))
  38 + {
  39 + continue;
  40 + }
  41 +
  42 + locationIds.Add(locKey);
  43 + }
  44 +
  45 + return locationIds.OrderBy(x => x, StringComparer.Ordinal).ToList();
  46 + }
  47 +
  48 + /// <summary>
  49 + /// 与 Team Member 详情/编辑回显一致:Company Admin 展示绑定 Company 下全部 Region,其它角色按门店反推 Region。
  50 + /// </summary>
  51 + public static async Task<List<string>> ResolveDisplayRegionIdsForUserAsync(
  52 + ISqlSugarClient db,
  53 + Guid userId,
  54 + Guid? roleId)
  55 + {
  56 + var locationIds = await ResolveBoundLocationIdsForUserAsync(db, userId);
  57 + if (locationIds.Count == 0)
  58 + {
  59 + return new List<string>();
  60 + }
  61 +
  62 + var partnerIds = await ResolvePartnerIdsFromLocationIdsAsync(db, locationIds);
  63 + var regionIds = await ResolveGroupIdsFromLocationIdsAsync(db, locationIds);
  64 +
  65 + if (await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(db, roleId) && partnerIds.Count > 0)
  66 + {
  67 + var allRegions = await ResolveGroupIdsFromPartnerIdsAsync(db, partnerIds);
  68 + if (allRegions.Count > 0)
  69 + {
  70 + regionIds = allRegions;
  71 + }
  72 + }
  73 +
  74 + return regionIds;
  75 + }
  76 +
  77 + /// <summary>
  78 + /// 当前登录用户可见 Region Id(<c>fl_group.Id</c>),规则同 <see cref="ResolveDisplayRegionIdsForUserAsync"/>。
  79 + /// </summary>
  80 + public static async Task<List<string>> ResolveDisplayRegionIdsForCurrentUserAsync(
  81 + ISqlSugarClient db,
  82 + ICurrentUser currentUser)
  83 + {
  84 + if (currentUser.Id is null)
  85 + {
  86 + return new List<string>();
  87 + }
  88 +
  89 + var roleId = await db.Queryable<UserRoleEntity>()
  90 + .Where(ur => ur.UserId == currentUser.Id.Value)
  91 + .Select(ur => (Guid?)ur.RoleId)
  92 + .FirstAsync();
  93 +
  94 + return await ResolveDisplayRegionIdsForUserAsync(db, currentUser.Id.Value, roleId);
  95 + }
  96 +
  97 + /// <summary>
15 98 /// 列表筛选:门店 Id 优先,否则 Region(groupId),否则 Company(partnerId);均未传返回 null。
16 99 /// </summary>
17 100 public static async Task<List<string>?> ResolveFilteredLocationIdsForListAsync(
... ... @@ -55,7 +138,12 @@ public static class LocationScopeBindingHelper
55 138 var gName = g.GroupName?.Trim() ?? string.Empty;
56 139 var partner = await db.Queryable<FlPartnerDbEntity>()
57 140 .FirstAsync(x => !x.IsDeleted && x.Id == g.PartnerId);
58   - var pName = partner?.PartnerName?.Trim() ?? string.Empty;
  141 + if (partner is null)
  142 + {
  143 + return new List<string>();
  144 + }
  145 +
  146 + var pName = partner.PartnerName?.Trim() ?? string.Empty;
59 147 q = q.Where(x => x.GroupName == gName && x.Partner == pName);
60 148 }
61 149 else if (!string.IsNullOrWhiteSpace(pid))
... ... @@ -358,6 +446,35 @@ public static class LocationScopeBindingHelper
358 446 }
359 447  
360 448 /// <summary>
  449 + /// 解析 Company(<c>fl_partner.Id</c>)下全部 Region Id(<c>fl_group.Id</c>)。
  450 + /// </summary>
  451 + public static async Task<List<string>> ResolveGroupIdsFromPartnerIdsAsync(
  452 + ISqlSugarClient db,
  453 + IReadOnlyList<string>? partnerIds)
  454 + {
  455 + var ids = NormalizeIds(partnerIds);
  456 + if (ids.Count == 0)
  457 + {
  458 + return new List<string>();
  459 + }
  460 +
  461 + var result = new HashSet<string>(StringComparer.Ordinal);
  462 + foreach (var pid in ids)
  463 + {
  464 + var groupIds = await db.Queryable<FlGroupDbEntity>()
  465 + .Where(x => !x.IsDeleted && x.PartnerId == pid)
  466 + .Select(x => x.Id)
  467 + .ToListAsync();
  468 + foreach (var gid in groupIds.Where(x => !string.IsNullOrWhiteSpace(x)))
  469 + {
  470 + result.Add(gid.Trim());
  471 + }
  472 + }
  473 +
  474 + return result.OrderBy(x => x, StringComparer.Ordinal).ToList();
  475 + }
  476 +
  477 + /// <summary>
361 478 /// 校验合并后的门店 Id 均存在。
362 479 /// </summary>
363 480 public static async Task ValidateLocationIdsExistAsync(ISqlSugarClient db, IReadOnlyList<string> locationIds)
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/PartnerScopeHelper.cs
... ... @@ -39,37 +39,16 @@ public static class PartnerScopeHelper
39 39 return new PartnerScopeFilter();
40 40 }
41 41  
42   - var userId = currentUser.Id.Value.ToString();
43   - var locationIds = await dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
44   - .Where(x => !x.IsDeleted && x.UserId == userId)
45   - .Select(x => x.LocationId)
46   - .Distinct()
47   - .ToListAsync();
48   -
  42 + var db = dbContext.SqlSugarClient;
  43 + var locationIds = await LocationScopeBindingHelper.ResolveBoundLocationIdsForUserAsync(
  44 + db, currentUser.Id.Value);
49 45 if (locationIds.Count == 0)
50 46 {
51 47 return new PartnerScopeFilter();
52 48 }
53 49  
54   - var partnerKeys = await dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
55   - .Where(x => !x.IsDeleted && locationIds.Contains(x.Id.ToString()))
56   - .Where(x => x.Partner != null && x.Partner != string.Empty)
57   - .Select(x => x.Partner!)
58   - .Distinct()
59   - .ToListAsync();
60   -
61   - if (partnerKeys.Count == 0)
62   - {
63   - return new PartnerScopeFilter();
64   - }
65   -
66   - var allowedIds = await dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
67   - .Where(x => !x.IsDeleted)
68   - .Where(x => partnerKeys.Contains(x.PartnerName) || partnerKeys.Contains(x.Id))
69   - .Select(x => x.Id)
70   - .Distinct()
71   - .ToListAsync();
72   -
  50 + var allowedIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
  51 + db, locationIds);
73 52 return new PartnerScopeFilter { AllowedPartnerIds = allowedIds };
74 53 }
75 54  
... ... @@ -95,7 +74,8 @@ public static class PartnerScopeHelper
95 74 }
96 75  
97 76 /// <summary>
98   - /// 组织(fl_group)数据范围:管理员可见全部;非管理员仅可见其 <c>userlocation</c> 绑定门店对应的 Region(公司 + 组织名与 location 一致)。
  77 + /// 组织(fl_group)数据范围:管理员可见全部;Company Admin 可见绑定公司下全部 Region(与 Team Member 详情 <c>regionIds</c> 一致);
  78 + /// 其它非管理员仅可见其绑定门店反推的 Region。
99 79 /// </summary>
100 80 public sealed class GroupScopeFilter
101 81 {
... ... @@ -105,7 +85,7 @@ public static class PartnerScopeHelper
105 85 }
106 86  
107 87 /// <summary>
108   - /// 解析当前登录用户可查询的组织 Id 范围(与 Reports 按 location.Partner + location.GroupName 对齐)。
  88 + /// 解析当前登录用户可查询的组织 Id 范围(与 Team Member 编辑回显 <c>regionIds</c>/<c>groupIds</c> 对齐)。
109 89 /// </summary>
110 90 public static async Task<GroupScopeFilter> ResolveGroupScopeAsync(
111 91 ICurrentUser currentUser,
... ... @@ -121,62 +101,10 @@ public static class PartnerScopeHelper
121 101 return new GroupScopeFilter();
122 102 }
123 103  
124   - var userId = currentUser.Id.Value.ToString();
125   - var locationRows = await dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
126   - .InnerJoin<LocationAggregateRoot>((ul, loc) =>
127   - !loc.IsDeleted && ul.LocationId == loc.Id.ToString())
128   - .Where(ul => !ul.IsDeleted && ul.UserId == userId)
129   - .Select((ul, loc) => new { loc.Partner, loc.GroupName })
130   - .ToListAsync();
131   -
132   - if (locationRows.Count == 0)
133   - {
134   - return new GroupScopeFilter();
135   - }
  104 + var regionIds = await LocationScopeBindingHelper.ResolveDisplayRegionIdsForCurrentUserAsync(
  105 + dbContext.SqlSugarClient, currentUser);
136 106  
137   - var pairs = locationRows
138   - .Where(x => !string.IsNullOrWhiteSpace(x.GroupName))
139   - .Select(x => (Partner: (x.Partner ?? string.Empty).Trim(), GroupName: x.GroupName!.Trim()))
140   - .Where(x => !string.IsNullOrEmpty(x.GroupName) && !string.IsNullOrEmpty(x.Partner))
141   - .Distinct()
142   - .ToList();
143   -
144   - if (pairs.Count == 0)
145   - {
146   - return new GroupScopeFilter();
147   - }
148   -
149   - var partnerKeys = pairs.Select(x => x.Partner).Distinct().ToList();
150   - var partners = await dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
151   - .Where(x => !x.IsDeleted)
152   - .Where(x => partnerKeys.Contains(x.PartnerName) || partnerKeys.Contains(x.Id))
153   - .Select(x => new { x.Id, x.PartnerName })
154   - .ToListAsync();
155   -
156   - var allowedGroupIds = new HashSet<string>(StringComparer.Ordinal);
157   - foreach (var pair in pairs)
158   - {
159   - var partnerId = partners
160   - .FirstOrDefault(p =>
161   - string.Equals(p.PartnerName, pair.Partner, StringComparison.Ordinal) ||
162   - string.Equals(p.Id, pair.Partner, StringComparison.Ordinal))
163   - ?.Id;
164   - if (string.IsNullOrEmpty(partnerId))
165   - {
166   - continue;
167   - }
168   -
169   - var ids = await dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
170   - .Where(g => !g.IsDeleted && g.PartnerId == partnerId && g.GroupName == pair.GroupName)
171   - .Select(g => g.Id)
172   - .ToListAsync();
173   - foreach (var id in ids)
174   - {
175   - allowedGroupIds.Add(id);
176   - }
177   - }
178   -
179   - return new GroupScopeFilter { AllowedGroupIds = allowedGroupIds.ToList() };
  107 + return new GroupScopeFilter { AllowedGroupIds = regionIds };
180 108 }
181 109  
182 110 /// <summary>
... ... @@ -191,12 +119,13 @@ public static class PartnerScopeHelper
191 119 return query;
192 120 }
193 121  
194   - var ids = scope.AllowedGroupIds;
195   - if (ids.Count == 0)
  122 + var allowedGuids = TeamMemberListScopeHelper.ParseGuidHashSet(scope.AllowedGroupIds);
  123 + if (allowedGuids.Count == 0)
196 124 {
197 125 return query.Where((g, p) => false);
198 126 }
199 127  
200   - return query.Where((g, p) => ids.Contains(g.Id));
  128 + var guidList = allowedGuids.ToList();
  129 + return query.Where((g, p) => guidList.Contains(SqlFunc.ToGuid(g.Id)));
201 130 }
202 131 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/RbacAccessPermissionHelper.cs
  1 +using System.Text.Json;
  2 +
1 3 namespace FoodLabeling.Application.Helpers;
2 4  
3 5 /// <summary>
... ... @@ -45,11 +47,37 @@ public static class RbacAccessPermissionHelper
45 47 }
46 48  
47 49 /// <summary>
48   - /// 解析 accessPermissions 入参(英文逗号分隔,忽略大小写)
  50 + /// 解析 accessPermissions 入参:支持 JSON 数组字符串(如 <c>["manage_labels"]</c>)、英文逗号分隔
49 51 /// </summary>
50 52 public static List<string> ParseAccessPermissionCodes(string accessPermissions)
51 53 {
52   - return accessPermissions
  54 + var raw = accessPermissions?.Trim();
  55 + if (string.IsNullOrWhiteSpace(raw))
  56 + {
  57 + return new List<string>();
  58 + }
  59 +
  60 + if (raw.StartsWith('['))
  61 + {
  62 + try
  63 + {
  64 + var fromJson = JsonSerializer.Deserialize<List<string>>(raw);
  65 + if (fromJson is { Count: > 0 })
  66 + {
  67 + return fromJson
  68 + .Where(c => !string.IsNullOrWhiteSpace(c))
  69 + .Select(c => c.Trim())
  70 + .Distinct(StringComparer.OrdinalIgnoreCase)
  71 + .ToList();
  72 + }
  73 + }
  74 + catch
  75 + {
  76 + // fall through to delimiter split
  77 + }
  78 + }
  79 +
  80 + return raw
53 81 .Split(new[] { ',', ';', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
54 82 .Where(c => !string.IsNullOrWhiteSpace(c))
55 83 .Select(c => c.Trim())
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogDailyLabelIdHelper.cs
  1 +using System.Globalization;
1 2 using FoodLabeling.Application.Services.DbModels;
2 3 using SqlSugar;
3 4  
... ... @@ -128,6 +129,88 @@ public static class ReportsPrintLogDailyLabelIdHelper
128 129 return result;
129 130 }
130 131  
  132 + /// <summary>
  133 + /// App Print Log 单日筛选:解析 <paramref name="printDate"/> / <paramref name="printDateDay"/> 为服务器本地自然日。
  134 + /// 两者均未传时返回 null(不按日过滤,兼容 App 未传 printDate 的历史行为)。
  135 + /// </summary>
  136 + public static DateTime? ResolvePrintLogFilterCalendarDay(DateTime? printDate, string? printDateDay)
  137 + {
  138 + var text = printDateDay?.Trim();
  139 + if (!string.IsNullOrWhiteSpace(text))
  140 + {
  141 + if (DateTime.TryParseExact(
  142 + text,
  143 + "yyyy-MM-dd",
  144 + CultureInfo.InvariantCulture,
  145 + DateTimeStyles.None,
  146 + out var parsedExact))
  147 + {
  148 + return parsedExact.Date;
  149 + }
  150 +
  151 + if (DateTime.TryParse(
  152 + text,
  153 + CultureInfo.InvariantCulture,
  154 + DateTimeStyles.AllowWhiteSpaces,
  155 + out var parsed))
  156 + {
  157 + return parsed.Date;
  158 + }
  159 +
  160 + throw new Volo.Abp.UserFriendlyException("printDate 格式不正确,请使用 yyyy-MM-dd");
  161 + }
  162 +
  163 + if (!printDate.HasValue)
  164 + {
  165 + return null;
  166 + }
  167 +
  168 + var value = printDate.Value;
  169 + return value.Kind == DateTimeKind.Utc ? value.ToLocalTime().Date : value.Date;
  170 + }
  171 +
  172 + /// <summary>
  173 + /// 按自然日过滤打印任务(MySQL <c>DATE(IFNULL(PrintedAt, CreationTime))</c>,避免 DateTime 区间比较时区偏差)。
  174 + /// </summary>
  175 + public static ISugarQueryable<FlLabelPrintTaskDbEntity> ApplyPrintTaskCalendarDayFilter(
  176 + ISugarQueryable<FlLabelPrintTaskDbEntity> query,
  177 + DateTime filterDay)
  178 + {
  179 + var dayText = filterDay.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
  180 + return query.Where("DATE(IFNULL(PrintedAt, CreationTime)) = @filterDay", new { filterDay = dayText });
  181 + }
  182 +
  183 + /// <summary>
  184 + /// 多表 Join 查询按自然日过滤(首表别名 <c>t</c>)。
  185 + /// </summary>
  186 + public static ISugarQueryable<FlLabelPrintTaskDbEntity, T2> ApplyPrintTaskCalendarDayFilter<T2>(
  187 + ISugarQueryable<FlLabelPrintTaskDbEntity, T2> query,
  188 + DateTime filterDay)
  189 + {
  190 + var dayText = filterDay.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
  191 + return query.Where("DATE(IFNULL(t.PrintedAt, t.CreationTime)) = @filterDay", new { filterDay = dayText });
  192 + }
  193 +
  194 + /// <summary>
  195 + /// 五表 Join(首表别名 <c>t</c>)。
  196 + /// </summary>
  197 + public static ISugarQueryable<FlLabelPrintTaskDbEntity, T2, T3, T4, T5> ApplyPrintTaskCalendarDayFilter<T2, T3, T4, T5>(
  198 + ISugarQueryable<FlLabelPrintTaskDbEntity, T2, T3, T4, T5> query,
  199 + DateTime filterDay)
  200 + {
  201 + var dayText = filterDay.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
  202 + return query.Where("DATE(IFNULL(t.PrintedAt, t.CreationTime)) = @filterDay", new { filterDay = dayText });
  203 + }
  204 +
  205 + /// <summary>
  206 + /// App Print Log 单日筛选:<c>printDate</c> 的自然日区间;未传则默认服务器当天。
  207 + /// </summary>
  208 + public static (DateTime dayStart, DateTime dayEndExcl) ResolvePrintLogFilterDayRange(DateTime? printDate)
  209 + {
  210 + var day = ResolvePrintLogFilterCalendarDay(printDate, null) ?? DateTime.Today;
  211 + return (day, day.AddDays(1));
  212 + }
  213 +
131 214 public readonly struct PrintTaskScopeKey
132 215 {
133 216 public PrintTaskScopeKey(string taskId, string? locationId, DateTime printedAt)
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExcelHelper.cs
... ... @@ -36,7 +36,7 @@ public static class ReportsPrintLogExcelHelper
36 36 ws.Cell(r, 3).Value = x.ProductCategoryName ?? string.Empty;
37 37 ws.Cell(r, 4).Value = x.LabelCategoryName ?? string.Empty;
38 38 ws.Cell(r, 5).Value = x.TemplateText ?? string.Empty;
39   - ws.Cell(r, 6).Value = x.PrintedAt ?? string.Empty;
  39 + ws.Cell(r, 6).Value = ReportsDateTimeDisplayHelper.FormatPrintedAt(x.PrintedAt);
40 40 ws.Cell(r, 7).Value = x.PrintedByName ?? string.Empty;
41 41 ws.Cell(r, 8).Value = x.LocationText ?? string.Empty;
42 42 ws.Cell(r, 9).Value = x.ExpiryDateText ?? string.Empty;
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs
... ... @@ -286,4 +286,8 @@ public static class ReportsPrintLogExpiryHelper
286 286  
287 287 return null;
288 288 }
  289 +
  290 + /// <summary>解析并格式化为 Print Log Expiration 列展示。</summary>
  291 + public static string ExtractFormattedExpiryText(string? printInputJson) =>
  292 + ReportsDateTimeDisplayHelper.FormatExpiryDisplay(ExtractExpiryText(printInputJson));
289 293 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberBatchExcelHelper.cs
... ... @@ -9,6 +9,54 @@ namespace FoodLabeling.Application.Helpers;
9 9 /// </summary>
10 10 public static class TeamMemberBatchExcelHelper
11 11 {
  12 + /// <summary>导入/下载模板表头(与 PDF 导出 Region 列对齐)</summary>
  13 + public static readonly string[] ImportTemplateHeaders =
  14 + {
  15 + "Name",
  16 + "User Name",
  17 + "Password",
  18 + "Email",
  19 + "Phone",
  20 + "Role Id",
  21 + "Role Name",
  22 + "Region",
  23 + "Assigned Location Ids",
  24 + "Status"
  25 + };
  26 +
  27 + /// <summary>生成批量导入模板 xlsx(含 Region 可选列说明行)</summary>
  28 + public static MemoryStream BuildImportTemplateWorkbook()
  29 + {
  30 + var ms = new MemoryStream();
  31 + using var wb = new XLWorkbook();
  32 + var ws = wb.AddWorksheet("TeamMembers");
  33 + for (var i = 0; i < ImportTemplateHeaders.Length; i++)
  34 + {
  35 + ws.Cell(1, i + 1).Value = ImportTemplateHeaders[i];
  36 + ws.Cell(1, i + 1).Style.Font.Bold = true;
  37 + }
  38 +
  39 + ws.Cell(2, 1).Value = "John Doe";
  40 + ws.Cell(2, 2).Value = "john.doe";
  41 + ws.Cell(2, 3).Value = "ChangeMe123!";
  42 + ws.Cell(2, 4).Value = "john@example.com";
  43 + ws.Cell(2, 5).Value = "789654444";
  44 + ws.Cell(2, 6).Value = "";
  45 + ws.Cell(2, 7).Value = "Staff";
  46 + ws.Cell(2, 8).Value = "";
  47 + ws.Cell(2, 9).Value = "LOC001;LOC002";
  48 + ws.Cell(2, 10).Value = "TRUE";
  49 +
  50 + ws.Cell(3, 7).Value = "(Role Name 与系统角色名一致;Company Admin 可只填 Region 或留空由 Company 规则处理)";
  51 + ws.Cell(4, 8).Value = "(可选)Region 名称或 fl_group.Id,多个用 ; 分隔";
  52 + ws.Cell(5, 9).Value = "(可选)门店 LocationCode 或 location.Id(Guid),多个用 ; 分隔;与 Region 至少填一项";
  53 +
  54 + ws.Columns().AdjustToContents();
  55 + wb.SaveAs(ms);
  56 + ms.Position = 0;
  57 + return ms;
  58 + }
  59 +
12 60 /// <summary>
13 61 /// 从上传的 Excel 解析为创建入参列表(行号从 2 起为数据行)。
14 62 /// </summary>
... ... @@ -162,7 +210,10 @@ public static class TeamMemberBatchExcelHelper
162 210 "password" or "pwd" or "密码" => "password",
163 211 "phone" or "mobile" or "电话" or "手机" => "phone",
164 212 "role" or "rolename" or "角色" => "rolename",
165   - "assignedlocations" or "locations" or "location" or "分配门店" or "门店" => "locations",
  213 + "roleid" or "角色id" => "roleid",
  214 + "region" or "regions" or "group" or "groupname" or "groupid" or "区域" => "regions",
  215 + "assignedlocations" or "assignedlocationids" or "locationids" or "locationcodes" or
  216 + "locations" or "location" or "分配门店" or "门店" or "门店id" => "locations",
166 217 "status" or "active" or "state" or "启用" => "status",
167 218 _ => null
168 219 };
... ... @@ -221,7 +272,9 @@ public static class TeamMemberBatchExcelHelper
221 272 var userName = GetCellByField(colMap, ws, rowNum, "username");
222 273 var password = GetCellByField(colMap, ws, rowNum, "password");
223 274 var phoneStr = GetCellByField(colMap, ws, rowNum, "phone");
  275 + var roleIdCell = GetCellByField(colMap, ws, rowNum, "roleid");
224 276 var roleName = GetCellByField(colMap, ws, rowNum, "rolename");
  277 + var regionsCell = GetCellByField(colMap, ws, rowNum, "regions");
225 278 var locationsCell = GetCellByField(colMap, ws, rowNum, "locations");
226 279 var statusStr = GetCellByField(colMap, ws, rowNum, "status");
227 280  
... ... @@ -264,9 +317,13 @@ public static class TeamMemberBatchExcelHelper
264 317 }
265 318  
266 319 Guid? roleIdResolved = null;
267   - if (string.IsNullOrWhiteSpace(roleName))
  320 + if (Guid.TryParse(roleIdCell?.Trim(), out var roleGuid))
268 321 {
269   - errors.Add("Role 不能为空");
  322 + roleIdResolved = roleGuid;
  323 + }
  324 + else if (string.IsNullOrWhiteSpace(roleName))
  325 + {
  326 + errors.Add("Role Name 不能为空(或填写有效的 Role Id Guid)");
270 327 }
271 328 else if (!roleNameToId.TryGetValue(NormalizeRoleKey(roleName), out var rid))
272 329 {
... ... @@ -277,10 +334,13 @@ public static class TeamMemberBatchExcelHelper
277 334 roleIdResolved = rid;
278 335 }
279 336  
280   - var locationTokens = SplitLocationTokens(locationsCell);
281   - if (locationTokens.Count == 0)
  337 + var regionTokens = SplitMultiValueTokens(regionsCell);
  338 + var locationTokens = SplitMultiValueTokens(locationsCell);
  339 + var isCompanyAdmin = TeamMemberRoleHelper.IsCompanyAdminRoleName(roleName);
  340 +
  341 + if (regionTokens.Count == 0 && locationTokens.Count == 0 && !isCompanyAdmin)
282 342 {
283   - errors.Add("Assigned Locations 不能为空(多个门店可用分号、竖线或换行分隔)");
  343 + errors.Add("Region 与 Assigned Location Ids 至少填一项(均可留空时仅适用于 Company Admin 且已在 Web 端配置 Company)");
284 344 }
285 345  
286 346 if (errors.Count > 0)
... ... @@ -297,6 +357,7 @@ public static class TeamMemberBatchExcelHelper
297 357 Password = pwd,
298 358 Phone = phone,
299 359 RoleId = roleIdResolved,
  360 + RegionIds = regionTokens,
300 361 LocationIds = locationTokens,
301 362 State = state
302 363 };
... ... @@ -308,23 +369,27 @@ public static class TeamMemberBatchExcelHelper
308 369 }
309 370  
310 371 /// <summary>
311   - /// 拆分门店单元格为「待解析」片段(后续由服务层解析为 Location Id)。
  372 + /// 拆分单元格为多个 token(门店/区域等;后续由服务层解析为 Id)。
312 373 /// </summary>
313   - public static List<string> SplitLocationTokens(string locationsCell)
  374 + public static List<string> SplitMultiValueTokens(string cell)
314 375 {
315   - if (string.IsNullOrWhiteSpace(locationsCell))
  376 + if (string.IsNullOrWhiteSpace(cell))
316 377 {
317 378 return new List<string>();
318 379 }
319 380  
320   - var parts = locationsCell
  381 + return cell
321 382 .Split(new[] { ';', '|', '\n', '\r', ',' }, StringSplitOptions.RemoveEmptyEntries)
322 383 .Select(x => x.Trim())
323 384 .Where(x => !string.IsNullOrEmpty(x))
324 385 .ToList();
325   - return parts;
326 386 }
327 387  
  388 + /// <summary>
  389 + /// 兼容旧调用。
  390 + /// </summary>
  391 + public static List<string> SplitLocationTokens(string locationsCell) => SplitMultiValueTokens(locationsCell);
  392 +
328 393 private static string RegexDigitsOnly(string s)
329 394 {
330 395 return new string(s.Where(char.IsDigit).ToArray());
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberListScopeHelper.cs
... ... @@ -9,6 +9,44 @@ namespace FoodLabeling.Application.Helpers;
9 9 public static class TeamMemberListScopeHelper
10 10 {
11 11 /// <summary>
  12 + /// 统一 Guid 字符串键(小写 D 格式),避免 userlocation.UserId 与 User.Id.ToString() 大小写不一致导致漏人。
  13 + /// </summary>
  14 + public static string NormalizeScopeKey(string? value)
  15 + {
  16 + if (string.IsNullOrWhiteSpace(value))
  17 + {
  18 + return string.Empty;
  19 + }
  20 +
  21 + return Guid.TryParse(value.Trim(), out var guid)
  22 + ? guid.ToString("D").ToLowerInvariant()
  23 + : value.Trim().ToLowerInvariant();
  24 + }
  25 +
  26 + public static string UserKey(Guid userId) => userId.ToString("D").ToLowerInvariant();
  27 +
  28 + public static HashSet<Guid> ParseGuidHashSet(IEnumerable<string?> raw)
  29 + {
  30 + var set = new HashSet<Guid>();
  31 + foreach (var item in raw)
  32 + {
  33 + if (string.IsNullOrWhiteSpace(item))
  34 + {
  35 + continue;
  36 + }
  37 +
  38 + if (Guid.TryParse(item.Trim(), out var guid))
  39 + {
  40 + set.Add(guid);
  41 + }
  42 + }
  43 +
  44 + return set;
  45 + }
  46 +
  47 + public static List<Guid> ParseGuidList(IEnumerable<string?> raw) =>
  48 + ParseGuidHashSet(raw).ToList();
  49 + /// <summary>
12 50 /// 解析列表/导出适用的门店 Id 范围(用于 <c>userlocation</c> 命中成员)。
13 51 /// <list type="bullet">
14 52 /// <item>管理员:仅受 Query 中 <c>partnerId</c> / <c>groupId</c> / <c>locationId</c> 约束;均未传则返回 <c>null</c>(不限制成员)。</item>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberRoleHelper.cs 0 → 100644
  1 +using SqlSugar;
  2 +using Volo.Abp.Users;
  3 +using Yi.Framework.Rbac.Domain.Entities;
  4 +
  5 +namespace FoodLabeling.Application.Helpers;
  6 +
  7 +/// <summary>
  8 +/// Team Member 角色识别(Company Admin / Partner Admin 等)。
  9 +/// </summary>
  10 +public static class TeamMemberRoleHelper
  11 +{
  12 + /// <summary>
  13 + /// UI 展示为 Company Admin,库内常见为 Partner Admin 或 RoleCode 含 partner。
  14 + /// </summary>
  15 + public static bool IsCompanyAdminRoleName(string? roleName)
  16 + {
  17 + if (string.IsNullOrWhiteSpace(roleName))
  18 + {
  19 + return false;
  20 + }
  21 +
  22 + var name = roleName.Trim();
  23 + if (string.Equals(name, "Company Admin", StringComparison.OrdinalIgnoreCase))
  24 + {
  25 + return true;
  26 + }
  27 +
  28 + if (string.Equals(name, "Partner Admin", StringComparison.OrdinalIgnoreCase))
  29 + {
  30 + return true;
  31 + }
  32 +
  33 + return name.Contains("partner", StringComparison.OrdinalIgnoreCase) &&
  34 + name.Contains("admin", StringComparison.OrdinalIgnoreCase);
  35 + }
  36 +
  37 + /// <summary>
  38 + /// 当前登录用户是否为 Company Admin 类角色。
  39 + /// </summary>
  40 + public static async Task<bool> IsCompanyAdminUserAsync(ISqlSugarClient db, ICurrentUser currentUser)
  41 + {
  42 + if (currentUser.Id is null)
  43 + {
  44 + return false;
  45 + }
  46 +
  47 + var roleId = await db.Queryable<UserRoleEntity>()
  48 + .Where(ur => ur.UserId == currentUser.Id.Value)
  49 + .Select(ur => (Guid?)ur.RoleId)
  50 + .FirstAsync();
  51 +
  52 + return await IsCompanyAdminRoleAsync(db, roleId);
  53 + }
  54 +
  55 + /// <summary>
  56 + /// 是否 Company Admin 类角色(按 RoleId 查库)。
  57 + /// </summary>
  58 + public static async Task<bool> IsCompanyAdminRoleAsync(ISqlSugarClient db, Guid? roleId)
  59 + {
  60 + if (roleId is null)
  61 + {
  62 + return false;
  63 + }
  64 +
  65 + var role = await db.Queryable<RoleAggregateRoot>()
  66 + .FirstAsync(x => !x.IsDeleted && x.Id == roleId.Value);
  67 + if (role is null)
  68 + {
  69 + return false;
  70 + }
  71 +
  72 + if (IsCompanyAdminRoleName(role.RoleName))
  73 + {
  74 + return true;
  75 + }
  76 +
  77 + var code = role.RoleCode?.Trim();
  78 + return !string.IsNullOrEmpty(code) &&
  79 + code.Contains("partner", StringComparison.OrdinalIgnoreCase);
  80 + }
  81 +
  82 + /// <summary>
  83 + /// 列表/详情出参:Partner Admin → Company Admin(与 Web UI 一致)。
  84 + /// </summary>
  85 + public static string? FormatDisplayRoleName(string? roleName)
  86 + {
  87 + if (IsCompanyAdminRoleName(roleName) &&
  88 + !string.Equals(roleName?.Trim(), "Company Admin", StringComparison.OrdinalIgnoreCase))
  89 + {
  90 + return "Company Admin";
  91 + }
  92 +
  93 + return roleName;
  94 + }
  95 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/UsAppAuthScopeHelper.cs
1 1 using System.Text.Json;
2 2 using FoodLabeling.Application.Contracts;
  3 +using FoodLabeling.Application.Contracts.Dtos.AuthScope;
3 4 using FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
4 5 using FoodLabeling.Application.Services.DbModels;
5 6 using FoodLabeling.Domain.Entities;
... ... @@ -94,11 +95,13 @@ public static class UsAppAuthScopeHelper
94 95 var partner = await db.Queryable<FlPartnerDbEntity>()
95 96 .FirstAsync(x => !x.IsDeleted && x.Id == partnerId.Trim());
96 97  
97   - var locations = await db.Queryable<LocationAggregateRoot>()
  98 + var locations = (await db.Queryable<LocationAggregateRoot>()
98 99 .Where(x => !x.IsDeleted && ids.Contains(x.Id.ToString()))
99 100 .OrderBy(x => x.OrderNum)
100   - .ThenBy(x => x.LocationName)
101   - .ToListAsync();
  101 + .ToListAsync())
  102 + .OrderBy(x => x.OrderNum)
  103 + .ThenBy(x => x.LocationName, StringComparer.OrdinalIgnoreCase)
  104 + .ToList();
102 105  
103 106 return locations.Select(x => new AuthScopeLocationOptionDto
104 107 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs
... ... @@ -54,9 +54,11 @@ public class DashboardAppService : ApplicationService, IDashboardAppService
54 54 db.Queryable<FlLabelPrintTaskDbEntity>(), scopeLocationIds)
55 55 .CountAsync(x => x.CreationTime >= yesterdayStart && x.CreationTime < todayStart);
56 56  
57   - var activeTemplates = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  57 + var activeTemplates = await LabelTemplateQueryHelper.ProjectListColumns(
  58 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
58 59 .CountAsync(x => !x.IsDeleted && x.State);
59   - var activeTemplatesPrevWeek = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  60 + var activeTemplatesPrevWeek = await LabelTemplateQueryHelper.ProjectListColumns(
  61 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
60 62 .CountAsync(x => !x.IsDeleted && x.State && x.CreationTime < weekStart);
61 63  
62 64 var activeUsers = await DashboardScopeHelper.CountScopedTeamMembersAsync(
... ... @@ -166,7 +168,7 @@ public class DashboardAppService : ApplicationService, IDashboardAppService
166 168 .Select((t, l, p, tpl) => new
167 169 {
168 170 t.Id,
169   - LabelCode = l.LabelCode,
  171 + t.LocationId,
170 172 LabelName = l.LabelName,
171 173 ProductName = p.ProductName,
172 174 tpl.Width,
... ... @@ -178,6 +180,13 @@ public class DashboardAppService : ApplicationService, IDashboardAppService
178 180 })
179 181 .ToListAsync();
180 182  
  183 + var dailyLabelIdMap = await ReportsPrintLogDailyLabelIdHelper.ResolveDailyLabelIdsAsync(
  184 + db,
  185 + recentRaw.Select(x => new ReportsPrintLogDailyLabelIdHelper.PrintTaskScopeKey(
  186 + x.Id,
  187 + x.LocationId,
  188 + x.PrintedAt ?? DateTime.MinValue)).ToList());
  189 +
181 190 var recentUserIds = recentRaw
182 191 .Select(x => x.CreatedBy)
183 192 .Where(x => !string.IsNullOrWhiteSpace(x))
... ... @@ -209,10 +218,11 @@ public class DashboardAppService : ApplicationService, IDashboardAppService
209 218 : (string.IsNullOrWhiteSpace(x.LabelName) ? "无" : x.LabelName.Trim());
210 219 var printedAt = x.PrintedAt ?? DateTime.MinValue;
211 220 var status = ResolveRecentLabelStatus(x.PrintInputJson);
  221 + var labelDisplayId = dailyLabelIdMap.TryGetValue(x.Id, out var dailyId) ? dailyId : "无";
212 222 return new DashboardRecentLabelItemDto
213 223 {
214 224 TaskId = x.Id,
215   - LabelCode = string.IsNullOrWhiteSpace(x.LabelCode) ? "无" : x.LabelCode.Trim(),
  225 + LabelCode = labelDisplayId,
216 226 DisplayName = displayName,
217 227 PrintedByUserId = x.CreatedBy?.Trim(),
218 228 PrintedByName = ResolveRecentUserName(recentUserMap, x.CreatedBy),
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTemplateDbEntity.cs
... ... @@ -34,12 +34,6 @@ public class FlLabelTemplateDbEntity
34 34  
35 35 public string AppliedLocationType { get; set; } = "ALL";
36 36  
37   - /// <summary>适用 Company 范围:ALL / SPECIFIED(见 fl_label_template_partner)</summary>
38   - public string AppliedPartnerType { get; set; } = "ALL";
39   -
40   - /// <summary>适用 Region 范围:ALL / SPECIFIED(见 fl_label_template_region)</summary>
41   - public string AppliedRegionType { get; set; } = "ALL";
42   -
43 37 public bool ShowRuler { get; set; }
44 38  
45 39 public bool ShowGrid { get; set; }
... ... @@ -54,4 +48,3 @@ public class FlLabelTemplateDbEntity
54 48  
55 49 public bool State { get; set; }
56 50 }
57   -
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs
... ... @@ -298,7 +298,8 @@ public class LabelAppService : ApplicationService, ILabelAppService
298 298 throw new UserFriendlyException("标签不存在");
299 299 }
300 300  
301   - var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  301 + var template = await LabelTemplateQueryHelper.ProjectListColumns(
  302 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
302 303 .FirstAsync(x => x.Id == label.TemplateId);
303 304 var category = await _dbContext.SqlSugarClient.Queryable<FlLabelCategoryDbEntity>()
304 305 .FirstAsync(x => x.Id == label.LabelCategoryId);
... ... @@ -404,7 +405,8 @@ public class LabelAppService : ApplicationService, ILabelAppService
404 405 input.LocationId,
405 406 input.LocationIds);
406 407  
407   - var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  408 + var template = await LabelTemplateQueryHelper.ProjectListColumns(
  409 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
408 410 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim());
409 411 if (template is null)
410 412 {
... ... @@ -520,7 +522,8 @@ public class LabelAppService : ApplicationService, ILabelAppService
520 522 input.LocationId,
521 523 input.LocationIds);
522 524  
523   - var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  525 + var template = await LabelTemplateQueryHelper.ProjectListColumns(
  526 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
524 527 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == input.TemplateCode.Trim());
525 528 if (template is null)
526 529 {
... ... @@ -671,7 +674,8 @@ public class LabelAppService : ApplicationService, ILabelAppService
671 674 }
672 675  
673 676 // 取模板头 & elements
674   - var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  677 + var template = await LabelTemplateQueryHelper.ProjectListColumns(
  678 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
675 679 .FirstAsync(x => !x.IsDeleted && x.Id == label.TemplateId);
676 680 if (template is null)
677 681 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs
... ... @@ -29,10 +29,11 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
29 29  
30 30 public async Task<PagedResultWithPageDto<LabelTemplateGetListOutputDto>> GetListAsync(LabelTemplateGetListInputVo input)
31 31 {
  32 + input ??= new LabelTemplateGetListInputVo();
32 33 RefAsync<int> total = 0;
33 34 var keyword = input.Keyword?.Trim();
34   - var scopedLocationIds = await LocationScopeBindingHelper.ResolveScopedLocationIdsAsync(
35   - _dbContext.SqlSugarClient, input.GroupId, input.LocationId);
  35 + var scopedLocationIds = await LocationScopeBindingHelper.ResolveFilteredLocationIdsForListAsync(
  36 + _dbContext.SqlSugarClient, input.PartnerId, input.GroupId, input.LocationId);
36 37  
37 38 var query = _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
38 39 .Where(x => !x.IsDeleted)
... ... @@ -44,25 +45,30 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
44 45 query = await LabelTemplateScopeHelper.ApplyTemplateScopeFilterAsync(
45 46 _dbContext.SqlSugarClient, query, scopedLocationIds);
46 47  
47   - query = !string.IsNullOrWhiteSpace(input.Sorting)
48   - ? query.OrderBy(input.Sorting)
49   - : query.OrderByDescending(x => x.LastModificationTime ?? x.CreationTime);
  48 + query = ApplyLabelTemplateListSorting(query, input.Sorting);
  49 + query = LabelTemplateQueryHelper.ProjectListColumns(query);
50 50  
51   - var pageEntities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
  51 + var pageSize = input.MaxResultCount <= 0 ? 10 : input.MaxResultCount;
  52 + var pageEntities = await query.ToPageListAsync(input.SkipCount, pageSize, total);
52 53 var templateIds = pageEntities.Select(x => x.Id).ToList();
53 54  
54   - // element count (Contents)
55   - var elementCounts = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateElementDbEntity>()
56   - .Where(x => templateIds.Contains(x.TemplateId))
57   - .GroupBy(x => x.TemplateId)
58   - .Select(x => new { TemplateId = x.TemplateId, Count = SqlFunc.AggregateCount(x.Id) })
59   - .ToListAsync();
60   - var elementCountMap = elementCounts.ToDictionary(x => x.TemplateId, x => (int)x.Count);
  55 + var elementCountMap = new Dictionary<string, int>(StringComparer.Ordinal);
  56 + if (templateIds.Count > 0)
  57 + {
  58 + var elementCounts = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateElementDbEntity>()
  59 + .Where(x => templateIds.Contains(x.TemplateId))
  60 + .GroupBy(x => x.TemplateId)
  61 + .Select(x => new { TemplateId = x.TemplateId, Count = SqlFunc.AggregateCount(x.Id) })
  62 + .ToListAsync();
  63 + elementCountMap = elementCounts.ToDictionary(x => x.TemplateId, x => (int)x.Count);
  64 + }
61 65  
62 66 var scopeMap = await LabelTemplateScopeHelper.BuildScopeDisplayMapAsync(
63 67 _dbContext.SqlSugarClient, pageEntities);
64   - var itemsMap = await LabelTemplateListItemsHelper.ResolveTemplateItemsMapAsync(
65   - _dbContext.SqlSugarClient, templateIds);
  68 + var itemsMap = templateIds.Count > 0
  69 + ? await LabelTemplateListItemsHelper.ResolveTemplateItemsMapAsync(
  70 + _dbContext.SqlSugarClient, templateIds)
  71 + : new Dictionary<string, LabelTemplateListItemsHelper.TemplateItemsDisplay>(StringComparer.Ordinal);
66 72  
67 73 var items = pageEntities.Select(x =>
68 74 {
... ... @@ -97,12 +103,69 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
97 103 };
98 104 }).ToList();
99 105  
100   - return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
  106 + return BuildPagedResult(input.SkipCount, pageSize, total, items);
  107 + }
  108 +
  109 + /// <summary>
  110 + /// 列表排序:白名单字段 + IFNULL(LastModificationTime, CreationTime),避免 <c>??</c> 与原始 Sorting 拼接导致 SQL 异常。
  111 + /// </summary>
  112 + private static ISugarQueryable<FlLabelTemplateDbEntity> ApplyLabelTemplateListSorting(
  113 + ISugarQueryable<FlLabelTemplateDbEntity> query,
  114 + string? sorting)
  115 + {
  116 + if (!string.IsNullOrWhiteSpace(sorting))
  117 + {
  118 + var s = sorting.Trim();
  119 + if (s.Equals("TemplateName asc", StringComparison.OrdinalIgnoreCase))
  120 + {
  121 + return query.OrderBy(x => x.TemplateName);
  122 + }
  123 +
  124 + if (s.Equals("TemplateName desc", StringComparison.OrdinalIgnoreCase))
  125 + {
  126 + return query.OrderByDescending(x => x.TemplateName);
  127 + }
  128 +
  129 + if (s.Equals("TemplateCode asc", StringComparison.OrdinalIgnoreCase))
  130 + {
  131 + return query.OrderBy(x => x.TemplateCode);
  132 + }
  133 +
  134 + if (s.Equals("TemplateCode desc", StringComparison.OrdinalIgnoreCase))
  135 + {
  136 + return query.OrderByDescending(x => x.TemplateCode);
  137 + }
  138 +
  139 + if (s.Equals("CreationTime asc", StringComparison.OrdinalIgnoreCase))
  140 + {
  141 + return query.OrderBy(x => x.CreationTime);
  142 + }
  143 +
  144 + if (s.Equals("CreationTime desc", StringComparison.OrdinalIgnoreCase))
  145 + {
  146 + return query.OrderByDescending(x => x.CreationTime);
  147 + }
  148 +
  149 + if (s.Equals("LastModificationTime asc", StringComparison.OrdinalIgnoreCase))
  150 + {
  151 + return query.OrderBy(x => x.LastModificationTime);
  152 + }
  153 +
  154 + if (s.Equals("LastModificationTime desc", StringComparison.OrdinalIgnoreCase))
  155 + {
  156 + return query.OrderByDescending(x => x.LastModificationTime);
  157 + }
  158 + }
  159 +
  160 + return query
  161 + .OrderBy(x => SqlFunc.IsNull(x.LastModificationTime, x.CreationTime), OrderByType.Desc)
  162 + .OrderBy(x => x.TemplateCode, OrderByType.Asc);
101 163 }
102 164  
103 165 public async Task<LabelTemplateGetOutputDto> GetAsync(string id)
104 166 {
105   - var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  167 + var template = await LabelTemplateQueryHelper.ProjectListColumns(
  168 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
106 169 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == id);
107 170 if (template is null)
108 171 {
... ... @@ -180,8 +243,8 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
180 243 Width = template.Width,
181 244 Height = template.Height,
182 245 AppliedLocationType = template.AppliedLocationType,
183   - AppliedPartnerType = template.AppliedPartnerType,
184   - AppliedRegionType = template.AppliedRegionType,
  246 + AppliedPartnerType = LabelTemplateScopeHelper.ScopeAll,
  247 + AppliedRegionType = LabelTemplateScopeHelper.ScopeAll,
185 248 ShowRuler = template.ShowRuler,
186 249 ShowGrid = template.ShowGrid,
187 250 BorderType = NormalizeTemplateBorderType(template.BorderType),
... ... @@ -210,7 +273,8 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
210 273 throw new UserFriendlyException("模板名称不能为空");
211 274 }
212 275  
213   - var duplicated = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  276 + var duplicated = await LabelTemplateQueryHelper.ProjectListColumns(
  277 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
214 278 .AnyAsync(x => !x.IsDeleted && x.TemplateCode == code);
215 279 if (duplicated)
216 280 {
... ... @@ -237,8 +301,6 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
237 301 Width = input.Width,
238 302 Height = input.Height,
239 303 AppliedLocationType = scope.AppliedLocationType,
240   - AppliedPartnerType = scope.AppliedPartnerType,
241   - AppliedRegionType = scope.AppliedRegionType,
242 304 ShowRuler = input.ShowRuler,
243 305 ShowGrid = input.ShowGrid,
244 306 BorderType = NormalizeTemplateBorderType(input.BorderType),
... ... @@ -262,13 +324,20 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
262 324 CurrentUser?.Id?.ToString(),
263 325 now);
264 326  
  327 + await LabelTemplateScopeSchemaHelper.SetAppliedScopeTypesAsync(
  328 + _dbContext.SqlSugarClient,
  329 + entity.Id,
  330 + scope.AppliedPartnerType,
  331 + scope.AppliedRegionType);
  332 +
265 333 return await GetAsync(code);
266 334 }
267 335  
268 336 [UnitOfWork]
269 337 public async Task<LabelTemplateGetOutputDto> UpdateAsync(string id, LabelTemplateUpdateInputVo input)
270 338 {
271   - var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  339 + var template = await LabelTemplateQueryHelper.ProjectListColumns(
  340 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
272 341 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == id);
273 342 if (template is null)
274 343 {
... ... @@ -279,7 +348,8 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
279 348 var name = input.TemplateName?.Trim();
280 349 if (!string.IsNullOrWhiteSpace(code) && !string.Equals(code, template.TemplateCode, StringComparison.OrdinalIgnoreCase))
281 350 {
282   - var duplicated = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  351 + var duplicated = await LabelTemplateQueryHelper.ProjectListColumns(
  352 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
283 353 .AnyAsync(x => !x.IsDeleted && x.TemplateCode == code);
284 354 if (duplicated)
285 355 {
... ... @@ -295,8 +365,6 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
295 365 template.Width = input.Width;
296 366 template.Height = input.Height;
297 367 template.AppliedLocationType = scope.AppliedLocationType;
298   - template.AppliedPartnerType = scope.AppliedPartnerType;
299   - template.AppliedRegionType = scope.AppliedRegionType;
300 368 template.ShowRuler = input.ShowRuler;
301 369 template.ShowGrid = input.ShowGrid;
302 370 template.BorderType = NormalizeTemplateBorderType(input.BorderType);
... ... @@ -325,13 +393,20 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
325 393 CurrentUser?.Id?.ToString(),
326 394 template.LastModificationTime ?? DateTime.Now);
327 395  
  396 + await LabelTemplateScopeSchemaHelper.SetAppliedScopeTypesAsync(
  397 + _dbContext.SqlSugarClient,
  398 + template.Id,
  399 + scope.AppliedPartnerType,
  400 + scope.AppliedRegionType);
  401 +
328 402 return await GetAsync(template.TemplateCode);
329 403 }
330 404  
331 405 [UnitOfWork]
332 406 public async Task DeleteAsync(string id)
333 407 {
334   - var template = await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  408 + var template = await LabelTemplateQueryHelper.ProjectListColumns(
  409 + _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>())
335 410 .FirstAsync(x => !x.IsDeleted && x.TemplateCode == id);
336 411 if (template is null)
337 412 {
... ... @@ -358,12 +433,15 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
358 433 await _dbContext.SqlSugarClient.Deleteable<FlLabelTemplateLocationDbEntity>()
359 434 .Where(x => x.TemplateId == template.Id)
360 435 .ExecuteCommandAsync();
361   - await _dbContext.SqlSugarClient.Deleteable<FlLabelTemplatePartnerDbEntity>()
362   - .Where(x => x.TemplateId == template.Id)
363   - .ExecuteCommandAsync();
364   - await _dbContext.SqlSugarClient.Deleteable<FlLabelTemplateRegionDbEntity>()
365   - .Where(x => x.TemplateId == template.Id)
366   - .ExecuteCommandAsync();
  436 + if (await LabelTemplateScopeSchemaHelper.HasPartnerRegionScopeTablesAsync(_dbContext.SqlSugarClient))
  437 + {
  438 + await _dbContext.SqlSugarClient.Deleteable<FlLabelTemplatePartnerDbEntity>()
  439 + .Where(x => x.TemplateId == template.Id)
  440 + .ExecuteCommandAsync();
  441 + await _dbContext.SqlSugarClient.Deleteable<FlLabelTemplateRegionDbEntity>()
  442 + .Where(x => x.TemplateId == template.Id)
  443 + .ExecuteCommandAsync();
  444 + }
367 445 await _dbContext.SqlSugarClient.Deleteable<FlLabelTemplateProductDefaultDbEntity>()
368 446 .Where(x => x.TemplateId == template.Id)
369 447 .ExecuteCommandAsync();
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ReportsAppService.cs
... ... @@ -135,7 +135,7 @@ public class ReportsAppService : ApplicationService, IReportsAppService
135 135 : x.LabelCategoryName!.Trim(),
136 136 CategoryName = string.IsNullOrWhiteSpace(cat) ? "无" : cat,
137 137 TemplateText = string.IsNullOrWhiteSpace(templateText) ? "无" : templateText,
138   - PrintedAt = ReportsDateTimeDisplayHelper.FormatPrintedAt(printedAt),
  138 + PrintedAt = printedAt,
139 139 PrintedByName = ResolveUserName(userMap, x.CreatedBy),
140 140 LocationText = locText,
141 141 LocationId = x.LocationId?.Trim(),
... ... @@ -997,7 +997,7 @@ public class ReportsAppService : ApplicationService, IReportsAppService
997 997 : x.LabelCategoryName!.Trim(),
998 998 CategoryName = string.IsNullOrWhiteSpace(cat) ? "无" : cat,
999 999 TemplateText = string.IsNullOrWhiteSpace(templateText) ? "无" : templateText,
1000   - PrintedAt = ReportsDateTimeDisplayHelper.FormatPrintedAt(printedAt),
  1000 + PrintedAt = printedAt,
1001 1001 PrintedByName = ResolveUserName(userMap, x.CreatedBy),
1002 1002 LocationText = locText,
1003 1003 LocationId = x.LocationId?.Trim(),
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs
... ... @@ -120,6 +120,14 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
120 120 var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
121 121 _dbContext.SqlSugarClient, locationIds);
122 122  
  123 + if (await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(_dbContext.SqlSugarClient, role?.RoleId) &&
  124 + partnerIds.Count > 0)
  125 + {
  126 + (regionIds, assigned) = await ApplyCompanyAdminDisplayScopeAsync(
  127 + role?.RoleId, partnerIds, regionIds, assigned);
  128 + locationIds = assigned.Select(x => x.Id).ToList();
  129 + }
  130 +
123 131 return new TeamMemberGetOutputDto
124 132 {
125 133 Id = user.Id,
... ... @@ -140,7 +148,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
140 148 /// <inheritdoc />
141 149 public async Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input)
142 150 {
143   - var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input);
  151 + var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input, input.RoleId);
144 152  
145 153 var user = new UserAggregateRoot
146 154 {
... ... @@ -245,25 +253,13 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
245 253 public Task<IActionResult> DownloadTeamMemberImportTemplateAsync()
246 254 {
247 255 var opt = _batchImportOptions.Value;
248   - var dir = opt.TemplateDirectory?.Trim();
249   - if (string.IsNullOrWhiteSpace(dir))
250   - {
251   - throw new UserFriendlyException("未配置批量导入模板目录 FoodLabeling:BatchImport:TemplateDirectory");
252   - }
253   -
254 256 var fileName = opt.TeamMemberTemplateFileName?.Trim();
255 257 if (string.IsNullOrWhiteSpace(fileName))
256 258 {
257   - throw new UserFriendlyException("未配置模板文件名 FoodLabeling:BatchImport:TeamMemberTemplateFileName");
  259 + fileName = "Team-Member-批量导入模板.xlsx";
258 260 }
259 261  
260   - var fullPath = Path.Combine(dir, fileName);
261   - if (!File.Exists(fullPath))
262   - {
263   - throw new UserFriendlyException($"模板文件不存在:{fullPath}");
264   - }
265   -
266   - var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
  262 + var stream = TeamMemberBatchExcelHelper.BuildImportTemplateWorkbook();
267 263 const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
268 264 return Task.FromResult<IActionResult>(new FileStreamResult(stream, contentType)
269 265 {
... ... @@ -294,6 +290,9 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
294 290 scopeLocationIds,
295 291 restrictAssignedLocationsToFilter: scopeLocationIds is not null);
296 292  
  293 + var regionNameMap = await LoadRegionNameMapAsync(
  294 + rows.SelectMany(r => r.RegionIds).Distinct(StringComparer.Ordinal));
  295 +
297 296 var fileName = $"team-members_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
298 297 var document = Document.Create(container =>
299 298 {
... ... @@ -306,11 +305,12 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
306 305 {
307 306 table.ColumnsDefinition(c =>
308 307 {
  308 + c.RelativeColumn(1.3f);
  309 + c.RelativeColumn(1.5f);
  310 + c.RelativeColumn(1.0f);
  311 + c.RelativeColumn(1.0f);
309 312 c.RelativeColumn(1.4f);
310   - c.RelativeColumn(1.6f);
311   - c.RelativeColumn(1.1f);
312   - c.RelativeColumn(1.1f);
313   - c.RelativeColumn(2.2f);
  313 + c.RelativeColumn(2.0f);
314 314 c.RelativeColumn(0.7f);
315 315 });
316 316  
... ... @@ -321,11 +321,19 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
321 321 table.Cell().Element(CellHeader).Text("Email");
322 322 table.Cell().Element(CellHeader).Text("Phone");
323 323 table.Cell().Element(CellHeader).Text("Role");
  324 + table.Cell().Element(CellHeader).Text("Region");
324 325 table.Cell().Element(CellHeader).Text("Assigned Locations");
325 326 table.Cell().Element(CellHeader).Text("Status");
326 327  
327 328 foreach (var e in rows)
328 329 {
  330 + var regionText = e.RegionIds.Count == 0
  331 + ? "无"
  332 + : string.Join("; ",
  333 + e.RegionIds.Select(id =>
  334 + regionNameMap.TryGetValue(id, out var name) && !string.IsNullOrWhiteSpace(name)
  335 + ? name
  336 + : id));
329 337 var locText = e.AssignedLocations.Count == 0
330 338 ? "无"
331 339 : string.Join("; ",
... ... @@ -341,6 +349,8 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
341 349 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
342 350 .Text(string.IsNullOrWhiteSpace(e.RoleName) ? "无" : e.RoleName);
343 351 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  352 + .Text(regionText);
  353 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
344 354 .Text(locText);
345 355 table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
346 356 .Text(status);
... ... @@ -399,7 +409,16 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
399 409 {
400 410 try
401 411 {
402   - vo.LocationIds = await ResolveLocationIdsFromImportTokensAsync(vo.LocationIds);
  412 + if (vo.RegionIds is { Count: > 0 })
  413 + {
  414 + vo.RegionIds = await ResolveRegionIdsFromImportTokensAsync(vo.RegionIds);
  415 + }
  416 +
  417 + if (vo.LocationIds is { Count: > 0 })
  418 + {
  419 + vo.LocationIds = await ResolveLocationIdsFromImportTokensAsync(vo.LocationIds);
  420 + }
  421 +
403 422 await CreateAsync(vo);
404 423 result.SuccessCount++;
405 424 }
... ... @@ -515,7 +534,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
515 534 .FirstAsync();
516 535 if (byCode is null)
517 536 {
518   - throw new UserFriendlyException($"未找到门店编码:{key}");
  537 + throw new UserFriendlyException($"未找到门店 LocationCode:{key}(亦可填 location.Id Guid)");
519 538 }
520 539  
521 540 result.Add(byCode.Id.ToString());
... ... @@ -524,6 +543,83 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
524 543 return result.Distinct().ToList();
525 544 }
526 545  
  546 + private async Task<List<string>> ResolveRegionIdsFromImportTokensAsync(List<string> tokens)
  547 + {
  548 + var result = new List<string>();
  549 + foreach (var raw in tokens)
  550 + {
  551 + var s = raw.Trim();
  552 + if (string.IsNullOrEmpty(s))
  553 + {
  554 + continue;
  555 + }
  556 +
  557 + var idx = s.IndexOf(" -", StringComparison.Ordinal);
  558 + var key = idx > 0 ? s[..idx].Trim() : s.Trim();
  559 +
  560 + if (Guid.TryParse(key, out _))
  561 + {
  562 + var byId = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
  563 + .Where(x => !x.IsDeleted && x.Id == key)
  564 + .FirstAsync();
  565 + if (byId is null)
  566 + {
  567 + throw new UserFriendlyException($"无效 Region Id:{key}");
  568 + }
  569 +
  570 + result.Add(byId.Id.Trim());
  571 + continue;
  572 + }
  573 +
  574 + var matches = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
  575 + .Where(x => !x.IsDeleted)
  576 + .ToListAsync();
  577 + matches = matches
  578 + .Where(x => string.Equals(x.GroupName?.Trim(), key, StringComparison.OrdinalIgnoreCase))
  579 + .ToList();
  580 +
  581 + if (matches.Count == 0)
  582 + {
  583 + throw new UserFriendlyException($"未找到 Region:{key}(可填 fl_group.Id 或 GroupName)");
  584 + }
  585 +
  586 + if (matches.Count > 1)
  587 + {
  588 + throw new UserFriendlyException(
  589 + $"Region 名称「{key}」存在多条记录,请改用 fl_group.Id(Guid)");
  590 + }
  591 +
  592 + result.Add(matches[0].Id.Trim());
  593 + }
  594 +
  595 + return result.Distinct(StringComparer.Ordinal).ToList();
  596 + }
  597 +
  598 + private async Task<Dictionary<string, string>> LoadRegionNameMapAsync(IEnumerable<string> regionIds)
  599 + {
  600 + var ids = regionIds
  601 + .Where(x => !string.IsNullOrWhiteSpace(x))
  602 + .Select(x => x.Trim())
  603 + .Distinct(StringComparer.Ordinal)
  604 + .ToList();
  605 + if (ids.Count == 0)
  606 + {
  607 + return new Dictionary<string, string>(StringComparer.Ordinal);
  608 + }
  609 +
  610 + var groups = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
  611 + .Where(x => !x.IsDeleted && ids.Contains(x.Id))
  612 + .Select(x => new { x.Id, x.GroupName })
  613 + .ToListAsync();
  614 +
  615 + return groups
  616 + .Where(x => !string.IsNullOrWhiteSpace(x.Id))
  617 + .ToDictionary(
  618 + x => x.Id.Trim(),
  619 + x => x.GroupName?.Trim() ?? x.Id.Trim(),
  620 + StringComparer.Ordinal);
  621 + }
  622 +
527 623 private async Task<ISugarQueryable<UserAggregateRoot>> BuildFilteredUserQueryAsync(
528 624 TeamMemberGetListInputVo input,
529 625 List<string>? scopeLocationIds)
... ... @@ -555,13 +651,30 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
555 651 }
556 652 else
557 653 {
558   - var scopeSet = new HashSet<string>(scopeLocationIds, StringComparer.Ordinal);
559   - var userIdStrs = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
560   - .Where(x => !x.IsDeleted && scopeSet.Contains(x.LocationId))
561   - .Select(x => x.UserId)
  654 + var scopeGuidSet = TeamMemberListScopeHelper.ParseGuidHashSet(scopeLocationIds);
  655 + var userLocationLinks = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  656 + .Where(x => !x.IsDeleted)
  657 + .Select(x => new { x.UserId, x.LocationId })
562 658 .ToListAsync();
563   - var allowed = new HashSet<string>(userIdStrs);
564   - query = query.Where(u => allowed.Contains(u.Id.ToString()));
  659 +
  660 + var allowedUserGuids = userLocationLinks
  661 + .Where(x =>
  662 + Guid.TryParse(x.LocationId, out var locGuid) && scopeGuidSet.Contains(locGuid))
  663 + .Select(x => x.UserId)
  664 + .Select(TeamMemberListScopeHelper.NormalizeScopeKey)
  665 + .Where(x => !string.IsNullOrEmpty(x))
  666 + .Select(x => Guid.Parse(x))
  667 + .Distinct()
  668 + .ToList();
  669 +
  670 + if (allowedUserGuids.Count == 0)
  671 + {
  672 + query = query.Where(_ => false);
  673 + }
  674 + else
  675 + {
  676 + query = query.Where(u => allowedUserGuids.Contains(u.Id));
  677 + }
565 678 }
566 679 }
567 680  
... ... @@ -579,43 +692,62 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
579 692 }
580 693  
581 694 var userIds = users.Select(x => x.Id).ToList();
582   - var userIdStrings = userIds.Select(x => x.ToString()).ToList();
  695 + var userGuidKeys = userIds.Select(TeamMemberListScopeHelper.UserKey).ToHashSet(StringComparer.Ordinal);
583 696  
584 697 var userRolePairs = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity, RoleAggregateRoot>((ur, r) => ur.RoleId == r.Id)
585 698 .Where(ur => userIds.Contains(ur.UserId))
586 699 .Select((ur, r) => new { ur.UserId, r.Id, r.RoleName })
587 700 .ToListAsync();
588 701  
589   - var roleMap = userRolePairs
  702 + var roleIdByUser = userRolePairs
590 703 .GroupBy(x => x.UserId)
591   - .ToDictionary(g => g.Key, g => g.FirstOrDefault());
  704 + .ToDictionary(g => g.Key, g => (Guid?)g.First().Id);
592 705  
593   - var userLocQuery = _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  706 + var allUserLocations = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
594 707 .Where(x => !x.IsDeleted)
595   - .Where(x => userIdStrings.Contains(x.UserId));
596   - if (restrictAssignedLocationsToFilter && scopeLocationIds is { Count: > 0 })
597   - {
598   - var scopeSet = new HashSet<string>(scopeLocationIds, StringComparer.Ordinal);
599   - userLocQuery = userLocQuery.Where(x => scopeSet.Contains(x.LocationId));
600   - }
  708 + .Select(x => new { x.UserId, x.LocationId })
  709 + .ToListAsync();
  710 +
  711 + var scopeGuidSet = scopeLocationIds is { Count: > 0 }
  712 + ? TeamMemberListScopeHelper.ParseGuidHashSet(scopeLocationIds)
  713 + : null;
601 714  
602   - var userLocations = await userLocQuery.ToListAsync();
  715 + var userLocations = allUserLocations
  716 + .Where(x => userGuidKeys.Contains(TeamMemberListScopeHelper.NormalizeScopeKey(x.UserId)))
  717 + .Where(x =>
  718 + scopeGuidSet is null ||
  719 + (Guid.TryParse(x.LocationId, out var locGuid) && scopeGuidSet.Contains(locGuid)))
  720 + .ToList();
603 721  
604 722 var locationIds = userLocations.Select(x => x.LocationId).Distinct().ToList();
  723 + var locationGuidList = TeamMemberListScopeHelper.ParseGuidList(locationIds);
605 724 var locations = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
606 725 .Where(x => !x.IsDeleted)
607   - .WhereIF(locationIds.Count > 0, x => locationIds.Contains(x.Id.ToString()))
  726 + .WhereIF(locationGuidList.Count > 0, x => locationGuidList.Contains(x.Id))
608 727 .Select(x => new { x.Id, x.LocationCode, x.LocationName })
609 728 .ToListAsync();
610   - var locationMap = locations.ToDictionary(x => x.Id.ToString(), x => x);
  729 + var locationMap = locations.ToDictionary(
  730 + x => TeamMemberListScopeHelper.UserKey(x.Id),
  731 + x => x);
611 732  
612 733 var assignedMap = userLocations
613   - .GroupBy(x => x.UserId)
  734 + .GroupBy(x => TeamMemberListScopeHelper.NormalizeScopeKey(x.UserId))
614 735 .ToDictionary(
615 736 g => g.Key,
616 737 g => g.Select(x =>
617 738 {
618   - if (locationMap.TryGetValue(x.LocationId, out var loc))
  739 + var locKey = TeamMemberListScopeHelper.NormalizeScopeKey(x.LocationId);
  740 + if (locationMap.TryGetValue(locKey, out var loc))
  741 + {
  742 + return new TeamMemberAssignedLocationDto
  743 + {
  744 + Id = loc.Id.ToString(),
  745 + LocationCode = loc.LocationCode,
  746 + LocationName = loc.LocationName
  747 + };
  748 + }
  749 +
  750 + if (locationMap.TryGetValue(x.LocationId, out loc))
619 751 {
620 752 return new TeamMemberAssignedLocationDto
621 753 {
... ... @@ -628,15 +760,31 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
628 760 return null;
629 761 }).Where(x => x != null).Cast<TeamMemberAssignedLocationDto>().ToList());
630 762  
631   - var scopeIdsMap = await BuildTeamMemberScopeIdsMapAsync(assignedMap);
  763 + var scopeIdsMap = await BuildTeamMemberScopeIdsMapAsync(assignedMap, roleIdByUser);
632 764  
633   - return users.Select(u =>
  765 + var items = new List<TeamMemberGetListOutputDto>();
  766 + foreach (var u in users)
634 767 {
635   - roleMap.TryGetValue(u.Id, out var role);
636   - assignedMap.TryGetValue(u.Id.ToString(), out var assigned);
637   - scopeIdsMap.TryGetValue(u.Id.ToString(), out var scopeIds);
  768 + roleIdByUser.TryGetValue(u.Id, out var listRoleId);
  769 + var roleName = userRolePairs.FirstOrDefault(x => x.UserId == u.Id)?.RoleName;
  770 + var userKey = TeamMemberListScopeHelper.UserKey(u.Id);
  771 + assignedMap.TryGetValue(userKey, out var assigned);
  772 + scopeIdsMap.TryGetValue(userKey, out var scopeIds);
  773 +
  774 + var partnerIds = scopeIds?.PartnerIds ?? new List<string>();
  775 + var regionIds = scopeIds?.RegionIds ?? new List<string>();
  776 + var assignedLocations = assigned ?? new List<TeamMemberAssignedLocationDto>();
  777 +
  778 + (regionIds, assignedLocations) = await ApplyCompanyAdminDisplayScopeAsync(
  779 + listRoleId, partnerIds, regionIds, assignedLocations);
  780 +
  781 + var locationIdList = assignedLocations
  782 + .Select(x => x.Id)
  783 + .Where(x => !string.IsNullOrWhiteSpace(x))
  784 + .Distinct(StringComparer.OrdinalIgnoreCase)
  785 + .ToList();
638 786  
639   - return new TeamMemberGetListOutputDto
  787 + items.Add(new TeamMemberGetListOutputDto
640 788 {
641 789 Id = u.Id,
642 790 FullName = u.Name ?? string.Empty,
... ... @@ -644,17 +792,21 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
644 792 Email = u.Email,
645 793 Phone = u.Phone,
646 794 State = u.State,
647   - RoleId = role?.Id,
648   - RoleName = role?.RoleName,
649   - PartnerIds = scopeIds?.PartnerIds ?? new List<string>(),
650   - RegionIds = scopeIds?.RegionIds ?? new List<string>(),
651   - AssignedLocations = assigned ?? new List<TeamMemberAssignedLocationDto>()
652   - };
653   - }).ToList();
  795 + RoleId = listRoleId,
  796 + RoleName = TeamMemberRoleHelper.FormatDisplayRoleName(roleName),
  797 + PartnerIds = partnerIds,
  798 + RegionIds = regionIds,
  799 + LocationIds = locationIdList,
  800 + AssignedLocations = assignedLocations
  801 + });
  802 + }
  803 +
  804 + return items;
654 805 }
655 806  
656 807 private async Task<Dictionary<string, TeamMemberScopeIds>> BuildTeamMemberScopeIdsMapAsync(
657   - Dictionary<string, List<TeamMemberAssignedLocationDto>> assignedMap)
  808 + Dictionary<string, List<TeamMemberAssignedLocationDto>> assignedMap,
  809 + Dictionary<Guid, Guid?> roleIdByUser)
658 810 {
659 811 var result = new Dictionary<string, TeamMemberScopeIds>(StringComparer.Ordinal);
660 812 foreach (var (userId, assigned) in assignedMap)
... ... @@ -665,16 +817,29 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
665 817 .Select(x => x.Trim())
666 818 .Distinct(StringComparer.Ordinal)
667 819 .ToList();
668   - if (locationIds.Count == 0)
  820 +
  821 + var partnerIds = locationIds.Count == 0
  822 + ? new List<string>()
  823 + : await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
  824 + _dbContext.SqlSugarClient, locationIds);
  825 + var regionIds = locationIds.Count == 0
  826 + ? new List<string>()
  827 + : await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
  828 + _dbContext.SqlSugarClient, locationIds);
  829 +
  830 + if (Guid.TryParse(userId, out var userGuid) &&
  831 + roleIdByUser.TryGetValue(userGuid, out var roleId) &&
  832 + await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(_dbContext.SqlSugarClient, roleId) &&
  833 + partnerIds.Count > 0)
669 834 {
670   - result[userId] = new TeamMemberScopeIds();
671   - continue;
  835 + var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(
  836 + _dbContext.SqlSugarClient, partnerIds);
  837 + if (allRegions.Count > 0)
  838 + {
  839 + regionIds = allRegions;
  840 + }
672 841 }
673 842  
674   - var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
675   - _dbContext.SqlSugarClient, locationIds);
676   - var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
677   - _dbContext.SqlSugarClient, locationIds);
678 843 result[userId] = new TeamMemberScopeIds
679 844 {
680 845 PartnerIds = partnerIds,
... ... @@ -685,6 +850,68 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
685 850 return result;
686 851 }
687 852  
  853 + /// <summary>
  854 + /// Company Admin 列表/详情:展示所选 Company 下全部 Region 与门店。
  855 + /// </summary>
  856 + private async Task<(List<string> RegionIds, List<TeamMemberAssignedLocationDto> AssignedLocations)>
  857 + ApplyCompanyAdminDisplayScopeAsync(
  858 + Guid? roleId,
  859 + List<string> partnerIds,
  860 + List<string> regionIds,
  861 + List<TeamMemberAssignedLocationDto> assigned)
  862 + {
  863 + if (!await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(_dbContext.SqlSugarClient, roleId) ||
  864 + partnerIds.Count == 0)
  865 + {
  866 + return (regionIds, assigned);
  867 + }
  868 +
  869 + var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(
  870 + _dbContext.SqlSugarClient, partnerIds);
  871 + var allLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(
  872 + _dbContext.SqlSugarClient, partnerIds);
  873 + var allAssigned = await BuildAssignedLocationDtosAsync(allLocationIds);
  874 +
  875 + return (
  876 + allRegions.Count > 0 ? allRegions : regionIds,
  877 + allAssigned.Count > 0 ? allAssigned : assigned);
  878 + }
  879 +
  880 + private async Task<List<TeamMemberAssignedLocationDto>> BuildAssignedLocationDtosAsync(
  881 + IReadOnlyList<string> locationIds)
  882 + {
  883 + var ids = LocationScopeBindingHelper.NormalizeIds(locationIds);
  884 + if (ids.Count == 0)
  885 + {
  886 + return new List<TeamMemberAssignedLocationDto>();
  887 + }
  888 +
  889 + var guidList = ids
  890 + .Select(x => Guid.TryParse(x, out var g) ? g : (Guid?)null)
  891 + .Where(x => x.HasValue)
  892 + .Select(x => x!.Value)
  893 + .ToList();
  894 + if (guidList.Count == 0)
  895 + {
  896 + return new List<TeamMemberAssignedLocationDto>();
  897 + }
  898 +
  899 + var locations = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  900 + .Where(x => !x.IsDeleted && guidList.Contains(x.Id))
  901 + .Select(x => new { x.Id, x.LocationCode, x.LocationName })
  902 + .ToListAsync();
  903 +
  904 + return locations
  905 + .OrderBy(x => x.LocationCode)
  906 + .Select(x => new TeamMemberAssignedLocationDto
  907 + {
  908 + Id = x.Id.ToString(),
  909 + LocationCode = x.LocationCode,
  910 + LocationName = x.LocationName
  911 + })
  912 + .ToList();
  913 + }
  914 +
688 915 private sealed class TeamMemberScopeIds
689 916 {
690 917 public List<string> PartnerIds { get; init; } = new();
... ... @@ -699,18 +926,41 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
699 926 RegionIds = input.RegionIds,
700 927 GroupIds = input.GroupIds,
701 928 LocationIds = input.LocationIds
702   - });
  929 + }, input.RoleId);
703 930  
704   - private async Task<List<string>> ResolveTeamMemberLocationIdsForSaveAsync(TeamMemberCreateInputVo input)
  931 + private async Task<List<string>> ResolveTeamMemberLocationIdsForSaveAsync(
  932 + TeamMemberCreateInputVo input,
  933 + Guid? roleId)
705 934 {
706 935 var partnerIds = NormalizePartnerIds(input);
707 936 var regionIds = NormalizeRegionIds(input);
  937 + var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds);
  938 + var isCompanyAdmin = await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(
  939 + _dbContext.SqlSugarClient, roleId);
  940 +
  941 + if (isCompanyAdmin && partnerIds.Count > 0 &&
  942 + regionIds.Count == 0 && explicitLocationIds.Count == 0)
  943 + {
  944 + var fromPartner = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
  945 + _dbContext.SqlSugarClient, partnerIds, null, null);
  946 + if (fromPartner.Count == 0)
  947 + {
  948 + throw new UserFriendlyException("Company Admin 需绑定公司下门店,所选公司下暂无可用门店");
  949 + }
  950 +
  951 + await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, fromPartner);
  952 + return fromPartner;
  953 + }
  954 +
708 955 var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
709 956 _dbContext.SqlSugarClient, partnerIds, regionIds, input.LocationIds);
710 957  
711 958 if (merged.Count == 0)
712 959 {
713   - throw new UserFriendlyException("成员必须至少分配一个门店(公司/区域/门店至少选一项)");
  960 + throw new UserFriendlyException(
  961 + isCompanyAdmin
  962 + ? "Company Admin 必须选择 Company,或指定 Region / 门店"
  963 + : "成员必须至少分配一个门店(公司/区域/门店至少选一项)");
714 964 }
715 965  
716 966 await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged);
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs
... ... @@ -7,6 +7,7 @@ using System.Security.Claims;
7 7 using System.Text;
8 8 using System.Threading.Tasks;
9 9 using FoodLabeling.Application.Contracts;
  10 +using FoodLabeling.Application.Contracts.Dtos.AuthScope;
10 11 using FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
11 12 using FoodLabeling.Application.Contracts.IServices;
12 13 using FoodLabeling.Application.Helpers;
... ... @@ -209,7 +210,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
209 210 /// <inheritdoc />
210 211 [Authorize]
211 212 public virtual async Task<AuthScopeSelectLocationOutputDto> SelectAdminScopeLocationAsync(
212   - UsAppSelectAdminScopeLocationInputVo input)
  213 + AuthScopeSelectLocationInputVo input)
213 214 {
214 215 UsAppAuthScopeHelper.EnsureAdminAppToken(CurrentUser);
215 216 if (!CurrentUser.Id.HasValue)
... ... @@ -221,7 +222,12 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
221 222 _dbContext.SqlSugarClient,
222 223 _distributedCache,
223 224 CurrentUser.Id.Value,
224   - input);
  225 + new UsAppSelectAdminScopeLocationInputVo
  226 + {
  227 + PartnerId = input.PartnerId,
  228 + GroupId = input.GroupId,
  229 + LocationId = input.LocationId
  230 + });
225 231 }
226 232  
227 233 /// <summary>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
... ... @@ -396,12 +396,16 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
396 396 }
397 397  
398 398 var previewProductId = await ResolvePreviewProductIdAsync(labelRow.Id, input.ProductId);
  399 + var baseTime = input.BaseTime ?? DateTime.Now;
  400 + var dailyLabelId = await ReportsPrintLogDailyLabelIdHelper.ResolveNextDailyLabelIdAsync(
  401 + _dbContext.SqlSugarClient, locationId, baseTime);
399 402  
400 403 var template = await _labelAppService.PreviewAsync(new LabelPreviewResolveInputVo
401 404 {
402 405 LabelCode = labelCode,
403 406 ProductId = previewProductId,
404 407 BaseTime = input.BaseTime,
  408 + LocationId = locationId,
405 409 PrintInputJson = input.PrintInputJson?.ToDictionary(x => x.Key, x => (object?)x.Value)
406 410 });
407 411  
... ... @@ -465,7 +469,7 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
465 469  
466 470 return new UsAppLabelPreviewDto
467 471 {
468   - LabelId = labelRow.Id,
  472 + LabelId = dailyLabelId,
469 473 PrintLabelDisplayId = printLabelDisplayId,
470 474 LocationId = locationId,
471 475 LabelCode = labelCode,
... ... @@ -568,6 +572,7 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
568 572 LabelCode = labelCode,
569 573 ProductId = previewProductId,
570 574 BaseTime = input.BaseTime,
  575 + LocationId = locationId,
571 576 PrintInputJson = normalizedPrintInput
572 577 });
573 578  
... ... @@ -856,6 +861,7 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
856 861 /// ```json
857 862 /// {
858 863 /// "locationId": "11111111-1111-1111-1111-111111111111",
  864 + /// "printDate": "2026-06-16T00:00:00",
859 865 /// "skipCount": 1,
860 866 /// "maxResultCount": 20
861 867 /// }
... ... @@ -863,6 +869,7 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
863 869 ///
864 870 /// 参数说明:
865 871 /// - locationId: 当前门店 Id(必填)
  872 + /// - printDate: 打印日期(自然日,可选;未传默认当天;按 PrintedAt ?? CreationTime 筛选)
866 873 /// - skipCount: 页码(从 1 开始,遵循本项目约定)
867 874 /// - maxResultCount: 每页条数
868 875 /// </remarks>
... ... @@ -918,8 +925,18 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
918 925  
919 926 RefAsync<int> total = 0;
920 927  
921   - var query = UsAppPrintLogScopeHelper.BuildLocationPrintTaskQuery(
922   - _dbContext.SqlSugarClient, locationId, restrictToCreator, currentUserIdStr)
  928 + var filterDay = ReportsPrintLogDailyLabelIdHelper.ResolvePrintLogFilterCalendarDay(
  929 + input.PrintDate, input.PrintDateDay);
  930 +
  931 + var baseQuery = UsAppPrintLogScopeHelper.BuildLocationPrintTaskQuery(
  932 + _dbContext.SqlSugarClient, locationId, restrictToCreator, currentUserIdStr);
  933 +
  934 + if (filterDay.HasValue)
  935 + {
  936 + baseQuery = ReportsPrintLogDailyLabelIdHelper.ApplyPrintTaskCalendarDayFilter(baseQuery, filterDay.Value);
  937 + }
  938 +
  939 + var query = baseQuery
923 940 .OrderBy((t, l, p, lt, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime), OrderByType.Desc)
924 941 .OrderBy((t, l, p, lt, tpl) => t.CreationTime, OrderByType.Desc)
925 942 .Select((t, l, p, lt, tpl) => new
... ... @@ -943,6 +960,13 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
943 960  
944 961 var pageRows = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
945 962  
  963 + var dailyLabelIdMap = await ReportsPrintLogDailyLabelIdHelper.ResolveDailyLabelIdsAsync(
  964 + _dbContext.SqlSugarClient,
  965 + pageRows.Select(x => new ReportsPrintLogDailyLabelIdHelper.PrintTaskScopeKey(
  966 + x.Id,
  967 + locationId,
  968 + x.PrintedAt ?? x.CreationTime)).ToList());
  969 +
946 970 var operatorMap = await UsAppPrintLogScopeHelper.LoadOperatorNameMapAsync(
947 971 _dbContext.SqlSugarClient,
948 972 pageRows.Select(x => x.CreatedBy));
... ... @@ -952,7 +976,8 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
952 976 TaskId = x.Id,
953 977 BatchId = x.BatchId,
954 978 CopyIndex = x.CopyIndex,
955   - LabelId = x.LabelId,
  979 + LabelId = dailyLabelIdMap.TryGetValue(x.Id, out var dailyId) ? dailyId : "无",
  980 + LabelEntityId = x.LabelId ?? string.Empty,
956 981 LabelCode = x.LabelCode ?? string.Empty,
957 982 ProductId = x.ProductId,
958 983 ProductName = string.IsNullOrWhiteSpace(x.ProductName) ? "无" : x.ProductName.Trim(),
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json
... ... @@ -20,7 +20,7 @@
20 20 },
21 21 //应用启动:SelfUrl 供 Program.cs UseUrls 绑定;用 0.0.0.0 避免写成固定局域网 IP 在本机无该网卡时启动失败(WinError 10049)
22 22 "App": {
23   - "SelfUrl": "http://localhost:19001",
  23 + "SelfUrl": "http://192.168.31.88:19001",
24 24 "CorsOrigins": "http://localhost:19001;http://localhost:18000;http://localhost:5666;http://localhost:3000"
25 25 },
26 26 //配置
... ...
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
... ... @@ -187,10 +187,19 @@ function normLocationId(id: string | null | undefined): string {
187 187 return String(id ?? "").trim().toLowerCase();
188 188 }
189 189  
190   -function memberMatchesLocationScope(m: TeamMemberDto, allowedLocationIds: Set<string> | null): boolean {
  190 +function memberMatchesLocationScope(
  191 + m: TeamMemberDto,
  192 + allowedLocationIds: Set<string> | null,
  193 + allowedPartnerId?: string | null,
  194 +): boolean {
  195 + if (allowedPartnerId) {
  196 + const partnerKey = allowedPartnerId.trim().toLowerCase();
  197 + const partnerIds = (m.partnerIds ?? []).map((x) => String(x).trim().toLowerCase());
  198 + if (partnerIds.includes(partnerKey)) return true;
  199 + }
191 200 if (allowedLocationIds === null) return true;
192 201 if (allowedLocationIds.size === 0) return false;
193   - const ids = (m.locationIds ?? []).map((x) => String(x).trim()).filter(Boolean);
  202 + const ids = (m.locationIds ?? []).map((x) => normLocationId(x)).filter(Boolean);
194 203 return ids.some((id) => allowedLocationIds.has(id));
195 204 }
196 205  
... ... @@ -575,7 +584,7 @@ export function PeopleView({
575 584 if (memberLocationFilter !== "all" || !memberUsesClientScopeFilter) return null;
576 585 const ids = new Set<string>();
577 586 for (const loc of memberLocationsForSelect) {
578   - if (loc.id) ids.add(loc.id);
  587 + if (loc.id) ids.add(normLocationId(loc.id));
579 588 }
580 589 return ids;
581 590 }, [memberLocationFilter, memberUsesClientScopeFilter, memberLocationsForSelect]);
... ... @@ -772,11 +781,14 @@ export function PeopleView({
772 781 skipCount: 1,
773 782 maxResultCount: 500,
774 783 keyword: debouncedMemberKeyword || undefined,
  784 + partnerId: memberPartnerFilter !== "all" ? memberPartnerFilter : undefined,
  785 + groupId: memberGroupFilter !== "all" ? memberGroupFilter : undefined,
775 786 },
776 787 ac.signal,
777 788 );
  789 + const allowedPartnerId = memberPartnerFilter !== "all" ? memberPartnerFilter : null;
778 790 const filtered = (res.items ?? []).filter((m) =>
779   - memberMatchesLocationScope(m, memberAllowedLocationIds),
  791 + memberMatchesLocationScope(m, memberAllowedLocationIds, allowedPartnerId),
780 792 );
781 793 const total = filtered.length;
782 794 const start = (memberPageIndex - 1) * memberPageSize;
... ... @@ -788,6 +800,8 @@ export function PeopleView({
788 800 skipCount: Math.max(1, memberPageIndex),
789 801 maxResultCount: memberPageSize,
790 802 keyword: debouncedMemberKeyword || undefined,
  803 + partnerId: memberPartnerFilter !== "all" ? memberPartnerFilter : undefined,
  804 + groupId: memberGroupFilter !== "all" ? memberGroupFilter : undefined,
791 805 locationId: locationIdParam,
792 806 },
793 807 ac.signal,
... ...
美国版/Food Labeling Management Platform/src/services/teamMemberService.ts
... ... @@ -168,6 +168,8 @@ export async function getTeamMembers(
168 168 MaxResultCount: input.maxResultCount,
169 169 Keyword: input.keyword,
170 170 RoleId: input.roleId,
  171 + PartnerId: input.partnerId,
  172 + GroupId: input.groupId,
171 173 LocationId: input.locationId,
172 174 State: input.state,
173 175 Sorting: input.sorting,
... ...
美国版/Food Labeling Management Platform/src/types/teamMember.ts
... ... @@ -35,6 +35,8 @@ export type TeamMemberGetListInput = {
35 35 maxResultCount: number; // pageSize
36 36 keyword?: string;
37 37 roleId?: string;
  38 + partnerId?: string;
  39 + groupId?: string;
38 40 locationId?: string;
39 41 state?: boolean;
40 42 sorting?: string;
... ...
项目相关文档/6-11代码优化.md
1 1 # 6-11 代码优化
2 2  
3   -本文档说明 **2026-06-11** 对美国版 App **`POST /api/app/us-app-labeling/preview`** 出参 **`labelId`** 的格式调整。
  3 +本文档说明 **2026-06-11** 对美国版 App 标签预览/打印相关的两项**纯后端**改造(**不改 Web / App 前端**):
  4 +
  5 +1. **`POST /api/app/us-app-labeling/preview`** 出参 **`labelId`** 改为门店当日序号 `yyyyMMdd-n`
  6 +2. 模板 **Company 自动生成元素**:预览/打印时按门店从 **`fl_partner`** 填充公司名及可选地址字段
  7 +
  8 +测试环境:`http://flus-test.3ffoodsafety.com`
4 9  
5 10 ---
6 11  
7   -## 背景
  12 +## 一、Preview 出参 `labelId` 格式
  13 +
  14 +### 背景
8 15  
9 16 预览页「Label ID」原先返回 **`fl_label.Id`**(GUID,如 `3a2192be-7b8f-e3e8-db9c-3a5e627b9222`),与业务要求不符。
10 17  
... ... @@ -18,11 +25,7 @@
18 25  
19 26 格式:`{yyyyMMdd}-{n}`(`n` 从 1 递增,按 `PrintedAt ?? CreationTime` 所在自然日、同一 `locationId` 统计)。
20 27  
21   -测试环境:`http://flus-test.3ffoodsafety.com`
22   -
23   ----
24   -
25   -## 接口说明
  28 +### 接口说明
26 29  
27 30 | 项目 | 内容 |
28 31 |------|------|
... ... @@ -30,7 +33,7 @@
30 33 | 路径 | `/api/app/us-app-labeling/preview` |
31 34 | 鉴权 | Bearer Token |
32 35  
33   -### 入参(Body:`UsAppLabelPreviewInputVo`)
  36 +#### 入参(Body:`UsAppLabelPreviewInputVo`)
34 37  
35 38 | 字段 | 类型 | 必填 | 说明 |
36 39 |------|------|------|------|
... ... @@ -40,7 +43,7 @@
40 43 | `baseTime` | DateTime | 否 | 基准时间;影响模板内日期/时间控件,也用于确定「哪一天的序号」;未传则用服务端当前时间 |
41 44 | `printInputJson` | object | 否 | 打印时输入项 |
42 45  
43   -### 出参(`UsAppLabelPreviewDto`)变更
  46 +#### 出参(`UsAppLabelPreviewDto`)变更
44 47  
45 48 | 字段 | 变更前 | 变更后 |
46 49 |------|--------|--------|
... ... @@ -48,7 +51,7 @@
48 51  
49 52 其余字段(`locationId`、`labelCode`、`template`、`labelLastEdited` 等)不变。
50 53  
51   -### `labelId` 计算规则
  54 +#### `labelId` 计算规则
52 55  
53 56 1. 取 **`baseTime ?? 当前服务器时间`** 的日期部分 `yyyyMMdd`。
54 57 2. 统计该 **`locationId`** 在当日内已有打印任务数(`fl_label_print_task`,时间取 `PrintedAt ?? CreationTime`)。
... ... @@ -57,9 +60,7 @@
57 60  
58 61 > **注意**:`labelId` 不是 `fl_label.LabelCode`,也不是 `fl_label.Id`。标签主键如需内部关联,请使用打印任务创建后的 `taskId` 或 print-log 中的 `labelEntityId`。
59 62  
60   ----
61   -
62   -## 请求示例
  63 +### 请求示例(labelId)
63 64  
64 65 ```bash
65 66 curl -X POST "http://flus-test.3ffoodsafety.com/api/app/us-app-labeling/preview" \
... ... @@ -87,9 +88,7 @@ curl -X POST &quot;http://flus-test.3ffoodsafety.com/api/app/us-app-labeling/preview&quot;
87 88  
88 89 若该门店在 `2026-05-13` 已有 2 条打印记录,预览返回 `20260513-3`;当日首条预览为 `20260513-1`。
89 90  
90   ----
91   -
92   -## 涉及代码
  91 +### 涉及代码(labelId)
93 92  
94 93 | 文件 | 说明 |
95 94 |------|------|
... ... @@ -98,16 +97,14 @@ curl -X POST &quot;http://flus-test.3ffoodsafety.com/api/app/us-app-labeling/preview&quot;
98 97 | `Dtos/UsAppLabeling/UsAppLabelPreviewDto.cs` | `labelId` XML 注释 |
99 98 | `IServices/IUsAppLabelingAppService.cs` | 接口注释 |
100 99  
101   ----
102   -
103   -## 验证步骤
  100 +### 验证步骤(labelId)
104 101  
105 102 1. 选定门店,确认当日已有 N 条 `fl_label_print_task`(可用 print-log 列表核对)。
106 103 2. 调用 **preview**,`labelId` 应为 `{今日yyyyMMdd}-{N+1}`。
107 104 3. 执行 **print** 创建新任务后,print-log 中该任务 `labelId` 与预览时一致(同一时刻连续预览+打印)。
108 105 4. 修改 `baseTime` 为历史日期,序号应按该日任务数计算,而非「今天」。
109 106  
110   -### SQL 抽查
  107 +#### SQL 抽查
111 108  
112 109 ```sql
113 110 SELECT Id, LocationId, PrintedAt, CreationTime
... ... @@ -122,7 +119,206 @@ ORDER BY IFNULL(PrintedAt, CreationTime), Id;
122 119  
123 120 ---
124 121  
  122 +## 二、Company 自动生成元素(预览/打印填充)
  123 +
  124 +### 背景
  125 +
  126 +模板编辑器中的 **Company** 控件属于「按门店自动取数」类型。用户点击打印时,后端应:
  127 +
  128 +- **始终**展示当前门店所属 **Company 名称**(`fl_partner.PartnerName`)
  129 +- **仅当模板元素 config 中勾选** Address / City / State / Zip / Email 时,才追加对应行;未勾选或库中无值则不展示该字段
  130 +
  131 +勾选状态**不落独立表**,保存在 **`fl_label_template_element.ConfigJson`**(通过 `POST/PUT /api/app/label-template` 的 `elements[].config` 写入)。公司主数据来自 **`fl_partner`**。
  132 +
  133 +> 本次**不提供** Web 属性面板 UI;需通过 **label-template 保存接口** 或 SQL 维护 `config`。
  134 +
  135 +### 元素识别规则
  136 +
  137 +满足以下任一条件即视为 Company 自动生成元素(`PartnerCompanyDisplayHelper.IsCompanyAutoElement`):
  138 +
  139 +| 条件 | 说明 |
  140 +|------|------|
  141 +| `typeAdd = "auto_Company"` | 标准 Company 控件 |
  142 +| `valueSourceType = "AUTO_DB"` 且 `typeAdd` 以 `auto_` 开头且含 `Company` | 兼容命名 |
  143 +
  144 +常见组合:`elementType = "TEXT_STATIC"`,`valueSourceType = "AUTO_DB"`,`typeAdd = "auto_Company"`。
  145 +
  146 +### 模板 config(勾选字段)
  147 +
  148 +写入 `elements[].config`(JSON),支持两种等价写法(可混用):
  149 +
  150 +**方式 A:数组 `companyIncludeFields`**
  151 +
  152 +| 数组值 | 含义 | 数据来源(`fl_partner`) |
  153 +|--------|------|--------------------------|
  154 +| `address` | 街道地址 | `Street` |
  155 +| `city` | 城市 | `City` |
  156 +| `state` | 州/省 | `StateCode` |
  157 +| `zip` | 邮编 | `ZipCode` |
  158 +| `email` | 邮箱 | `ContactEmail` |
  159 +
  160 +**方式 B:布尔开关**
  161 +
  162 +`includeAddress` / `includeCity` / `includeState` / `includeZip` / `includeEmail`(`true` 表示勾选)
  163 +
  164 +**示例(保存模板时)**
  165 +
  166 +```json
  167 +{
  168 + "elementType": "TEXT_STATIC",
  169 + "valueSourceType": "AUTO_DB",
  170 + "typeAdd": "auto_Company",
  171 + "name": "Company",
  172 + "config": {
  173 + "companyIncludeFields": ["address", "city", "state", "zip", "email"]
  174 + }
  175 +}
  176 +```
  177 +
  178 +或:
  179 +
  180 +```json
  181 +"config": {
  182 + "includeAddress": true,
  183 + "includeCity": true,
  184 + "includeState": true,
  185 + "includeZip": true,
  186 + "includeEmail": false
  187 +}
  188 +```
  189 +
  190 +### 展示文本格式
  191 +
  192 +后端将解析结果写入 **`template.elements[].config.text`**(多行,`\n` 分隔):
  193 +
  194 +| 行序 | 内容 | 规则 |
  195 +|------|------|------|
  196 +| 第 1 行 | 公司名 | **固定输出**(有 `PartnerName` 时) |
  197 +| 第 2 行 | 街道 | 仅勾选 `address` 且 `Street` 非空 |
  198 +| 第 3 行 | 城市, 州, 邮编 | 勾选 city/state/zip 中任一项时,按「City, StateCode, ZipCode」逗号拼接(空段跳过) |
  199 +| 第 4 行 | 邮箱 | 仅勾选 `email` 且 `ContactEmail` 非空 |
  200 +
  201 +**渲染示例**
  202 +
  203 +```
  204 +Acme Foods Inc
  205 +123 Main Street
  206 +New York, NY, 10001
  207 +sales@acme.com
  208 +```
  209 +
  210 +若仅勾选公司名(无任何 include),则 `text` 仅一行公司名。
  211 +
  212 +### 门店 → Company 解析
  213 +
  214 +1. 入参 **`locationId`**(`location.Id`,Guid 字符串)
  215 +2. 查 `location` 表取 **`Partner`** 字段
  216 +3. 在 **`fl_partner`** 中按 **`Id = Partner`** 或 **`PartnerName = Partner`** 匹配(未删除)
  217 +4. 匹配失败时不抛错,`config.text` 保持原样(不填充)
  218 +
  219 +### 入参要求
  220 +
  221 +| 场景 | `locationId` |
  222 +|------|----------------|
  223 +| 模板**不含** Company 自动生成元素 | 可选(App preview 仍必填门店,与原有逻辑一致) |
  224 +| 模板**含** Company 自动生成元素 | **`LabelPreviewResolveInputVo.locationId` 必填**;缺失返回 **400**:`预览/打印需要 locationId 以填充 Company 信息` |
  225 +
  226 +**App 调用链**:`UsAppLabelPreviewInputVo.locationId` → 内部 `LabelAppService.PreviewAsync(LabelPreviewResolveInputVo)` 时须**原样传入** `locationId`。
  227 +
  228 +受影响接口(均通过 `LabelAppService.PreviewAsync` 渲染模板):
  229 +
  230 +| 方法 | 路径 | 说明 |
  231 +|------|------|------|
  232 +| POST | `/api/app/us-app-labeling/preview` | App 预览 |
  233 +| POST | `/api/app/us-app-labeling/print` | App 打印(落库前解析模板) |
  234 +| POST | `/api/app/label/preview`(或管理端等价预览) | Web 预览;含 Company 元素时 Body 须带 `locationId` |
  235 +
  236 +重打 **`reprint`** 使用历史任务 `RenderTemplateJson` 快照,**不再**重新解析 Company。
  237 +
  238 +### 出参变更
  239 +
  240 +无新增顶层字段;变更在 **`template.elements[]`** 内:
  241 +
  242 +| 字段 | 变更 |
  243 +|------|------|
  244 +| `elements[].config.text` | Company 元素由后端按上文规则写入多行文本 |
  245 +
  246 +### 数据库与脚本
  247 +
  248 +| 项 | 说明 |
  249 +|----|------|
  250 +| `fl_partner.PartnerName` | 公司名(必有) |
  251 +| `fl_partner.Street` / `City` / `StateCode` / `ZipCode` / `ContactEmail` | 可选展示字段 |
  252 +| `location.Partner` | 门店归属 Company 键(Id 或名称) |
  253 +| DDL | `scripts/fl_partner_add_address_columns.sql`(缺地址列时执行) |
  254 +
  255 +模板适用范围(Company/Region/Location 三维 scope)见 **`项目相关文档/6-4代码优化.md`**,与本节「元素内 Company 自动填值」相互独立。
  256 +
  257 +### 请求示例(含 Company 的 preview)
  258 +
  259 +```bash
  260 +curl -X POST "http://flus-test.3ffoodsafety.com/api/app/us-app-labeling/preview" \
  261 + -H "Authorization: Bearer {token}" \
  262 + -H "Content-Type: application/json" \
  263 + -d '{
  264 + "locationId": "550e8400-e29b-41d4-a716-446655440000",
  265 + "labelCode": "LBL0001",
  266 + "productId": "PROD001"
  267 + }'
  268 +```
  269 +
  270 +响应 `template.elements` 中 Company 元素片段示例:
  271 +
  272 +```json
  273 +{
  274 + "elementType": "TEXT_STATIC",
  275 + "typeAdd": "auto_Company",
  276 + "valueSourceType": "AUTO_DB",
  277 + "config": {
  278 + "companyIncludeFields": ["address", "city", "state", "zip"],
  279 + "text": "Acme Foods Inc\n123 Main Street\nNew York, NY, 10001"
  280 + }
  281 +}
  282 +```
  283 +
  284 +### 涉及代码(Company)
  285 +
  286 +| 文件 | 说明 |
  287 +|------|------|
  288 +| `Helpers/PartnerCompanyDisplayHelper.cs` | 识别元素、解析 config、格式化文本、门店→Partner |
  289 +| `Services/LabelAppService.cs` | `PreviewAsync` 填充 Company 的 `config.text` |
  290 +| `Dtos/Label/LabelPreviewResolveInputVo.cs` | 新增 `locationId` |
  291 +| `Services/UsAppLabelingAppService.cs` | preview/print 调用预览时传入 `locationId` |
  292 +
  293 +### 验证步骤(Company)
  294 +
  295 +1. 确认门店 `location.Partner` 能关联到有效 `fl_partner` 记录(地址列已执行 DDL)。
  296 +2. 模板含 `auto_Company` 元素,`config` 勾选若干 include 字段;保存后查 `fl_label_template_element.ConfigJson`。
  297 +3. 调用 **preview**,Body 带正确 `locationId`:`template.elements` 中 Company 的 `config.text` 行数与勾选一致。
  298 +4. 去掉 `locationId` 或传空 → 返回 400(仅当模板含 Company 元素时)。
  299 +5. 执行 **print**,检查 `fl_label_print_task.RenderTemplateJson` 内 Company `text` 与预览一致。
  300 +
  301 +#### SQL 抽查
  302 +
  303 +```sql
  304 +-- 门店归属 Company
  305 +SELECT l.Id, l.Partner, p.PartnerName, p.Street, p.City, p.StateCode, p.ZipCode, p.ContactEmail
  306 +FROM location l
  307 +LEFT JOIN fl_partner p ON (p.Id = l.Partner OR p.PartnerName = l.Partner) AND p.IsDeleted = 0
  308 +WHERE l.Id = '{locationId}' AND l.IsDeleted = 0;
  309 +
  310 +-- 模板 Company 元素 config
  311 +SELECT e.Id, e.TypeAdd, e.ValueSourceType, e.ConfigJson
  312 +FROM fl_label_template_element e
  313 +JOIN fl_label_template t ON t.Id = e.TemplateId
  314 +WHERE t.TemplateCode = '{templateCode}' AND e.IsDeleted = 0
  315 + AND e.TypeAdd = 'auto_Company';
  316 +```
  317 +
  318 +---
  319 +
125 320 ## 关联文档
126 321  
127 322 - 同序号规则:`项目相关文档/6-2代码优化.md` → **App `get-print-log-list` 的 Label ID**
  323 +- 模板三维 scope:`项目相关文档/6-4代码优化.md` → **`/api/app/label-template`**
128 324 - App 预览页读取字段:`labelId` / `LabelId`(`preview.vue`)
... ...
项目相关文档/6-16代码优化.md 0 → 100644
  1 +# 6-16 代码优化
  2 +
  3 +本文档说明 **2026-06-16** 对美国版 Web 管理端 **`GET /api/app/dashboard/overview`** 中 **Recent Labels** 区块 **Label ID** 展示规则的同步改造。
  4 +
  5 +测试环境:`http://flus-test.3ffoodsafety.com`
  6 +
  7 +---
  8 +
  9 +## 背景
  10 +
  11 +Dashboard「Recent Labels」列表原先 `recentLabels[].labelCode` 返回 **`fl_label.LabelCode`**(或标签业务编码),与业务要求的 **Label ID** 含义不一致。
  12 +
  13 +业务规则(与 App preview、Print Log、报表一致):
  14 +
  15 +**Label ID = 某门店在某个自然日内,每次打印任务按时间递增的序号**
  16 +
  17 +| 示例 | 含义 |
  18 +|------|------|
  19 +| `20260513-1` | 该门店 2026-05-13 当日第 1 次打印 |
  20 +| `20260513-2` | 同日第 2 次 |
  21 +| `20260514-1` | 次日重新从 1 计数 |
  22 +
  23 +格式:`{yyyyMMdd}-{n}`(`n` 从 1 递增;按 **`PrintedAt ?? CreationTime`** 所在自然日、同一 **`locationId`** 统计)。
  24 +
  25 +> **注意**:`labelCode` 字段名保持不变(前端 Recent Labels 已绑定该字段展示 Label ID),**语义**由「标签编码」改为「门店当日打印序号」。不是 `fl_label.Id`,也不是 `fl_label.LabelCode`。
  26 +
  27 +---
  28 +
  29 +## 接口说明
  30 +
  31 +| 项目 | 内容 |
  32 +|------|------|
  33 +| 方法 | `GET` |
  34 +| 路径 | `/api/app/dashboard/overview` |
  35 +| 鉴权 | Bearer Token(Web 管理端登录 Token) |
  36 +| 入参 | 无 |
  37 +
  38 +### 出参变更(`DashboardOverviewOutputDto.recentLabels[]`)
  39 +
  40 +| 字段 | 变更前 | 变更后 |
  41 +|------|--------|--------|
  42 +| **`labelCode`** | `fl_label.LabelCode`(标签业务编码) | 该打印任务在所属门店、打印日内的序号 **`yyyyMMdd-n`** |
  43 +
  44 +`recentLabels` 其余字段(`taskId`、`displayName`、`printedByName`、`printedAt`、`status`、`labelTypeBadge`)不变。
  45 +
  46 +Overview 其它区块(指标卡片、周趋势、分类分布等)不受影响。
  47 +
  48 +### `labelCode`(Label ID)计算规则
  49 +
  50 +与 **`POST /api/app/us-app-labeling/preview`**、**`GET /api/app/reports/print-log-list`** 共用 Helper:**`ReportsPrintLogDailyLabelIdHelper.ResolveDailyLabelIdsAsync`**。
  51 +
  52 +1. 取打印任务 **`PrintedAt ?? CreationTime`** 的日期部分 `yyyyMMdd`。
  53 +2. 在同一 **`locationId`**、同一自然日内,按 `PrintedAt ?? CreationTime` 升序、`Id` 升序对所有任务排序。
  54 +3. 第 `i` 条任务的 Label ID = **`{yyyyMMdd}-{i}`**(`i` 从 1 开始)。
  55 +4. Recent Labels 取权限范围内**最新 10 条**打印任务;每条任务的 `labelCode` 为其在**所属门店当日序列**中的序号(非全平台统一编号)。
  56 +
  57 +多门店场景:不同门店各自从 `-1` 计数;同一 Dashboard 列表可混合展示多个门店的记录,每条记录的 `labelCode` 仅对应该任务的 `locationId` + 打印日。
  58 +
  59 +---
  60 +
  61 +## 请求示例
  62 +
  63 +```bash
  64 +curl -X GET "http://flus-test.3ffoodsafety.com/api/app/dashboard/overview" \
  65 + -H "Authorization: Bearer {token}"
  66 +```
  67 +
  68 +### 响应片段(`recentLabels` 示例)
  69 +
  70 +```json
  71 +{
  72 + "recentLabels": [
  73 + {
  74 + "taskId": "1234567890123456789",
  75 + "labelCode": "20260604-3",
  76 + "displayName": "Organic Milk 1L",
  77 + "printedByName": "Alice",
  78 + "printedAt": "2026-06-04T14:22:00",
  79 + "status": "active",
  80 + "labelTypeBadge": "2\"x2\""
  81 + },
  82 + {
  83 + "taskId": "1234567890123456788",
  84 + "labelCode": "20260604-2",
  85 + "displayName": "Whole Wheat Bread",
  86 + "printedByName": "Bob",
  87 + "printedAt": "2026-06-04T11:05:00",
  88 + "status": "expired",
  89 + "labelTypeBadge": "4\"x2\""
  90 + }
  91 + ],
  92 + "generatedAt": "2026-06-04T15:00:00"
  93 +}
  94 +```
  95 +
  96 +若某任务无法解析门店或打印时间,则 `labelCode` 为 **`无`**。
  97 +
  98 +---
  99 +
  100 +## 涉及代码
  101 +
  102 +| 文件 | 说明 |
  103 +|------|------|
  104 +| `Services/DashboardAppService.cs` | `GetOverviewAsync`:`recentLabels` 查询增加 `LocationId`,调用 `ResolveDailyLabelIdsAsync` 填充 `labelCode` |
  105 +| `Dtos/Dashboard/DashboardRecentLabelItemDto.cs` | `LabelCode` XML 注释更新为当日序号语义 |
  106 +| `IServices/IDashboardAppService.cs` | 接口 `remarks` 补充 `recentLabels[].labelCode` 规则说明 |
  107 +| `Helpers/ReportsPrintLogDailyLabelIdHelper.cs` | 共用(与 6-11 preview / 6-2 print-log 一致) |
  108 +
  109 +---
  110 +
  111 +## 验证步骤
  112 +
  113 +1. 登录 Web 管理端,获取 Bearer Token。
  114 +2. 选定门店,确认某自然日已有 N 条 `fl_label_print_task`(可用 print-log 或 SQL 核对)。
  115 +3. 调用 **`GET /api/app/dashboard/overview`**,`recentLabels` 中对应任务的 **`labelCode`** 应与 print-log 列表中同一 `taskId` 的 Label ID 一致。
  116 +4. 跨日打印:次日首条应为 `{新日期}-1`。
  117 +5. 多门店:两门店同日各打印 1 次,两条记录的 `labelCode` 末尾序号均可为 `-1`(各自门店独立计数)。
  118 +
  119 +### SQL 抽查
  120 +
  121 +```sql
  122 +SELECT Id, LocationId, PrintedAt, CreationTime,
  123 + IFNULL(PrintedAt, CreationTime) AS EffectiveTime
  124 +FROM fl_label_print_task
  125 +WHERE LocationId = '{locationId}'
  126 + AND IFNULL(PrintedAt, CreationTime) >= '{yyyy-MM-dd}'
  127 + AND IFNULL(PrintedAt, CreationTime) < DATE_ADD('{yyyy-MM-dd}', INTERVAL 1 DAY)
  128 +ORDER BY EffectiveTime, Id;
  129 +```
  130 +
  131 +按行号 `1..N` 对应 `labelCode` 的 `-n` 部分;日期部分为 `yyyyMMdd`。
  132 +
  133 +---
  134 +
  135 +## 关联文档
  136 +
  137 +- Label ID 通用规则与 preview:`项目相关文档/6-11代码优化.md`
  138 +- App print-log Label ID:`项目相关文档/6-2代码优化.md`
  139 +- Dashboard 数据范围(管理员 / 门店绑定):`DashboardScopeHelper` 及接口 `IDashboardAppService` 注释
... ...
项目相关文档/6-17代码优化.md 0 → 100644
  1 +# 6-17 代码优化
  2 +
  3 +本文档说明 **2026-06-17** 对美国版的两项后端改造:
  4 +
  5 +1. **`GET /api/app/label-template`** 列表 500 修复(scope 库结构兼容)
  6 +2. **`POST /api/app/us-app-labeling/get-print-log-list`** 增加 **按自然日** 的打印时间筛选
  7 +
  8 +测试环境:`http://flus-test.3ffoodsafety.com`
  9 +
  10 +---
  11 +
  12 +## 一、label-template 列表修复
  13 +
  14 +Web 管理端 **Label Templates** 页加载失败,Network 中:
  15 +
  16 +`GET /api/app/label-template?SkipCount=1&MaxResultCount=10` 返回错误,页面提示 **Failed to load label templates**。
  17 +
  18 +### 根因
  19 +
  20 +6-4 改造引入 **Company / Region / Location** 三维适用范围后,代码会:
  21 +
  22 +1. 查询 `fl_label_template.AppliedPartnerType` / `AppliedRegionType`
  23 +2. 关联 `fl_label_template_partner` / `fl_label_template_region`
  24 +
  25 +测试库若**尚未执行**迁移脚本 `fl_label_template_scope.sql`,上述列/表不存在,SqlSugar 生成 SQL 时报 **Unknown column / Table doesn't exist**,接口 500。
  26 +
  27 +> `SkipCount=1` 表示**第 1 页**(页码从 1 起),不是报错原因。
  28 +
  29 +---
  30 +
  31 +### 修复说明
  32 +
  33 +#### 1. 库结构兼容(未迁移仍可列表)
  34 +
  35 +| 改动 | 说明 |
  36 +|------|------|
  37 +| `LabelTemplateScopeSchemaHelper` | 启动后首次请求检测 partner/region 关联表是否存在 |
  38 +| `FlLabelTemplateDbEntity` | `AppliedPartnerType` / `AppliedRegionType` 标记 `IsIgnore`,避免 SELECT 不存在列 |
  39 +| `LabelTemplateScopeHelper` | 未迁移时列表/详情 **仅 Location 维度** 过滤与展示;Company/Region 显示 `All Companies` / `All Regions` |
  40 +| 已迁移库 | 行为与 6-4 一致,按关联表判断 ALL/SPECIFIED |
  41 +
  42 +#### 2. 列表筛选补全
  43 +
  44 +`LabelTemplateGetListInputVo` 增加 **`partnerId`**(Company),与 `groupId`、`locationId` 按「门店优先」解析(`ResolveFilteredLocationIdsForListAsync`)。
  45 +
  46 +#### 3. Items 列
  47 +
  48 +`LabelTemplateListItemsHelper` 补全 `ElementKey` 查询字段,控件名称排序稳定。
  49 +
  50 +---
  51 +
  52 +### 接口说明(label-template)
  53 +
  54 +| 项目 | 内容 |
  55 +|------|------|
  56 +| 方法 | `GET` |
  57 +| 路径 | `/api/app/label-template` |
  58 +| 鉴权 | Bearer Token |
  59 +
  60 +### 入参(Query)
  61 +
  62 +| 参数 | 类型 | 必填 | 说明 |
  63 +|------|------|------|------|
  64 +| `SkipCount` | int | 否 | **页码,从 1 起**;第一页传 `1`(与全平台列表约定一致) |
  65 +| `MaxResultCount` | int | 否 | 每页条数,默认 10 |
  66 +| `Keyword` | string | 否 | 模板名称 / 编码模糊搜索 |
  67 +| `PartnerId` | string | 否 | 按 Company(`fl_partner.Id`)筛选 |
  68 +| `GroupId` | string | 否 | 按 Region(`fl_group.Id`)筛选 |
  69 +| `LocationId` | string | 否 | 按门店(`location.Id`)筛选;**优先于** Partner/Region |
  70 +| `LabelType` | string | 否 | 如 `PRICE` / `NUTRITION` |
  71 +| `State` | bool | 否 | 启用状态 |
  72 +| `Sorting` | string | 否 | 排序字段 |
  73 +
  74 +筛选解析顺序:**LocationId → GroupId → PartnerId**;均未传则不按门店范围收窄(仍受登录账号数据权限约束)。
  75 +
  76 +### 出参(`PagedResultWithPageDto<LabelTemplateGetListOutputDto>`)
  77 +
  78 +| 字段 | 说明 |
  79 +|------|------|
  80 +| `pageIndex` | 当前页码 |
  81 +| `pageSize` | 每页条数 |
  82 +| `totalCount` | 总条数 |
  83 +| `totalPages` | 总页数 |
  84 +| `items[]` | 模板列表 |
  85 +
  86 +**`items[]` 主要字段**
  87 +
  88 +| 字段 | 说明 |
  89 +|------|------|
  90 +| `id` / `templateCode` | 模板编码(前端主键) |
  91 +| `templateName` | 模板名称 |
  92 +| `company` / `region` / `location` | 三维适用范围展示文案 |
  93 +| `partnerIds` / `regionIds` / `locationIds` | 对应 Id 数组(ALL 时为空) |
  94 +| `items` / `itemNames` | 模板内控件名称(逗号拼接 / 数组) |
  95 +| `contentsCount` | 控件数量 |
  96 +| `sizeText` | 如 `4x2inch` |
  97 +| `lastEdited` | 最近编辑时间 |
  98 +
  99 +---
  100 +
  101 +### 请求示例(label-template)
  102 +
  103 +```bash
  104 +curl -G "http://flus-test.3ffoodsafety.com/api/app/label-template" \
  105 + -H "Authorization: Bearer {token}" \
  106 + --data-urlencode "SkipCount=1" \
  107 + --data-urlencode "MaxResultCount=10"
  108 +```
  109 +
  110 +带筛选:
  111 +
  112 +```bash
  113 +curl -G "http://flus-test.3ffoodsafety.com/api/app/label-template" \
  114 + -H "Authorization: Bearer {token}" \
  115 + --data-urlencode "SkipCount=1" \
  116 + --data-urlencode "MaxResultCount=10" \
  117 + --data-urlencode "PartnerId=1234567890123456789" \
  118 + --data-urlencode "Keyword=Price"
  119 +```
  120 +
  121 +### 响应片段(示例)
  122 +
  123 +```json
  124 +{
  125 + "pageIndex": 1,
  126 + "pageSize": 10,
  127 + "totalCount": 2,
  128 + "totalPages": 1,
  129 + "items": [
  130 + {
  131 + "id": "TPL-PRICE-01",
  132 + "templateCode": "TPL-PRICE-01",
  133 + "templateName": "Standard Price Label",
  134 + "company": "All Companies",
  135 + "region": "All Regions",
  136 + "location": "All Locations",
  137 + "items": "Label Name, Price, Barcode",
  138 + "itemNames": ["Label Name", "Price", "Barcode"],
  139 + "contentsCount": 3,
  140 + "sizeText": "2x2inch",
  141 + "lastEdited": "2026-06-04T10:00:00"
  142 + }
  143 + ]
  144 +}
  145 +```
  146 +
  147 +---
  148 +
  149 +## 数据库迁移(启用完整三维 scope)
  150 +
  151 +未执行前:列表可正常返回,但 **无法** 保存 Company/Region 多选明细。
  152 +
  153 +**脚本(可重复执行)**:
  154 +
  155 +`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_template_scope.sql`
  156 +
  157 +| 对象 | 说明 |
  158 +|------|------|
  159 +| `fl_label_template.AppliedPartnerType` | Company:ALL / SPECIFIED |
  160 +| `fl_label_template.AppliedRegionType` | Region:ALL / SPECIFIED |
  161 +| `fl_label_template_partner` | Company 多选明细 |
  162 +| `fl_label_template_region` | Region 多选明细 |
  163 +
  164 +执行后重启应用,新建/编辑模板即可写入 Company/Region 范围。
  165 +
  166 +### SQL 检查是否已迁移
  167 +
  168 +```sql
  169 +SELECT COLUMN_NAME
  170 +FROM information_schema.COLUMNS
  171 +WHERE TABLE_SCHEMA = DATABASE()
  172 + AND TABLE_NAME = 'fl_label_template'
  173 + AND COLUMN_NAME IN ('AppliedPartnerType', 'AppliedRegionType');
  174 +
  175 +SELECT TABLE_NAME
  176 +FROM information_schema.TABLES
  177 +WHERE TABLE_SCHEMA = DATABASE()
  178 + AND TABLE_NAME IN ('fl_label_template_partner', 'fl_label_template_region');
  179 +```
  180 +
  181 +---
  182 +
  183 +### 涉及代码(label-template)
  184 +
  185 +| 文件 | 说明 |
  186 +|------|------|
  187 +| `Helpers/LabelTemplateScopeSchemaHelper.cs` | 检测 scope 表是否已迁移 |
  188 +| `Helpers/LabelTemplateScopeHelper.cs` | 兼容未迁移库的过滤/展示/保存 |
  189 +| `Helpers/LabelTemplateListItemsHelper.cs` | Items 列 ElementKey |
  190 +| `Services/DbModels/FlLabelTemplateDbEntity.cs` | Partner/Region 列 IsIgnore |
  191 +| `Services/LabelTemplateAppService.cs` | 列表筛选支持 `partnerId` |
  192 +| `Dtos/LabelTemplate/LabelTemplateGetListInputVo.cs` | 新增 `PartnerId` |
  193 +| `IServices/ILabelTemplateAppService.cs` | 接口注释 |
  194 +
  195 +### 验证步骤(label-template)
  196 +
  197 +1. **未迁移库**:仅执行后端部署,调用 `GET /api/app/label-template?SkipCount=1&MaxResultCount=10` 应 **200**,Web 列表可加载。
  198 +2. **已迁移库**:列表 `company`/`region` 展示与 6-4 一致;指定 Company 保存后再查列表 Id 数组正确。
  199 +3. 分页:第 1 页 `SkipCount=1`,第 2 页 `SkipCount=2`,`totalCount` 与 UI 分页一致。
  200 +4. 筛选:传 `LocationId` 后仅返回该门店可见模板 + 全范围模板。
  201 +
  202 +---
  203 +
  204 +## 二、App Print Log 按日筛选(get-print-log-list)
  205 +
  206 +### 背景
  207 +
  208 +App **Print Log** 页增加 **Date** 选择器(及 **Today** 快捷按钮),需按所选自然日只展示该门店当日的打印记录;无记录时提示如 `No print records for 06/16/2026`。
  209 +
  210 +改造前接口仅按门店 + 权限分页,**无日期条件**,无法与 UI 日期筛选对齐。
  211 +
  212 +### 接口说明
  213 +
  214 +| 项目 | 内容 |
  215 +|------|------|
  216 +| 方法 | `POST` |
  217 +| 路径 | `/api/app/us-app-labeling/get-print-log-list` |
  218 +| 鉴权 | App Bearer Token |
  219 +
  220 +### 入参(Body:`PrintLogGetListInputVo`)
  221 +
  222 +| 字段 | 类型 | 必填 | 说明 |
  223 +|------|------|------|------|
  224 +| `locationId` | string | 是 | 当前门店 Id(`location.Id`) |
  225 +| **`printDate`** | DateTime | 否 | **打印日期(自然日)**;按 `PrintedAt ?? CreationTime` 落在该日的任务筛选 |
  226 +| `skipCount` | int | 否 | 页码,从 **1** 起 |
  227 +| `maxResultCount` | int | 否 | 每页条数 |
  228 +
  229 +#### `printDate` 规则
  230 +
  231 +1. **未传** → 默认 **服务器当天**(与 App 默认选中 Today 一致)。
  232 +2. **传入任意时刻** → 仅取 **日期部分**(`Date`),区间为 `[当天 00:00:00, 次日 00:00:00)`。
  233 +3. 有效时间字段:`fl_label_print_task.PrintedAt` 优先,为空则用 `CreationTime`(与 Label ID 序号、`6-2` / `6-11` 一致)。
  234 +4. 权限不变:管理员 / Partner 看门店全部;其它角色仅本人(`CreatedBy`)。
  235 +
  236 +### 出参
  237 +
  238 +仍为 `PagedResultWithPageDto<PrintLogItemDto>`;`items[]` 字段不变。`totalCount` 为**该日**符合条件的总数。
  239 +
  240 +| 字段 | 说明 |
  241 +|------|------|
  242 +| `labelId` | 门店当日打印序号 `yyyyMMdd-n`(与 preview / 报表一致) |
  243 +| `printedAt` | 打印时间 |
  244 +| 其它 | 与改造前一致 |
  245 +
  246 +### 请求示例
  247 +
  248 +**查询当天(显式传 printDate)**
  249 +
  250 +```bash
  251 +curl -X POST "http://flus-test.3ffoodsafety.com/api/app/us-app-labeling/get-print-log-list" \
  252 + -H "Authorization: Bearer {token}" \
  253 + -H "Content-Type: application/json" \
  254 + -d '{
  255 + "locationId": "550e8400-e29b-41d4-a716-446655440000",
  256 + "printDate": "2026-06-16T00:00:00",
  257 + "skipCount": 1,
  258 + "maxResultCount": 20
  259 + }'
  260 +```
  261 +
  262 +**Today 按钮(不传 printDate,后端默认当天)**
  263 +
  264 +```json
  265 +{
  266 + "locationId": "550e8400-e29b-41d4-a716-446655440000",
  267 + "skipCount": 1,
  268 + "maxResultCount": 20
  269 +}
  270 +```
  271 +
  272 +### 响应片段(示例)
  273 +
  274 +```json
  275 +{
  276 + "pageIndex": 1,
  277 + "pageSize": 20,
  278 + "totalCount": 2,
  279 + "totalPages": 1,
  280 + "items": [
  281 + {
  282 + "taskId": "1234567890123456789",
  283 + "labelId": "20260616-1",
  284 + "productName": "Organic Milk",
  285 + "printedAt": "2026-06-16T09:15:00",
  286 + "operatorName": "Alice",
  287 + "locationName": "Ordos Airport"
  288 + },
  289 + {
  290 + "taskId": "1234567890123456788",
  291 + "labelId": "20260616-2",
  292 + "printedAt": "2026-06-16T14:30:00"
  293 + }
  294 + ]
  295 +}
  296 +```
  297 +
  298 +若 `totalCount = 0`,App 可展示 `No print records for MM/DD/YYYY`。
  299 +
  300 +### 涉及代码(get-print-log-list)
  301 +
  302 +| 文件 | 说明 |
  303 +|------|------|
  304 +| `Dtos/UsAppLabeling/PrintLogGetListInputVo.cs` | 新增 `PrintDate` |
  305 +| `Services/UsAppLabelingAppService.cs` | `GetPrintLogListAsync` 增加日期 Where |
  306 +| `Helpers/ReportsPrintLogDailyLabelIdHelper.cs` | 新增 `ResolvePrintLogFilterDayRange` |
  307 +| `IServices/IUsAppLabelingAppService.cs` | 接口注释 |
  308 +
  309 +### 验证步骤(get-print-log-list)
  310 +
  311 +1. 门店在 `2026-06-16` 有 N 条打印任务,传 `printDate=2026-06-16` → `totalCount=N`,且每条 `printedAt` 落在该日。
  312 +2. 传 `printDate=2026-06-15` 且无记录 → `totalCount=0`,`items=[]`。
  313 +3. 不传 `printDate` → 等价于查询**服务器当天**。
  314 +4. 同一 `printDate` 下 `labelId` 序号与 `6-2` 规则一致(`-1`、`-2` … 按当日时间升序)。
  315 +
  316 +#### SQL 抽查
  317 +
  318 +```sql
  319 +SELECT Id, LocationId, PrintedAt, CreationTime
  320 +FROM fl_label_print_task
  321 +WHERE LocationId = '{locationId}'
  322 + AND IFNULL(PrintedAt, CreationTime) >= '2026-06-16'
  323 + AND IFNULL(PrintedAt, CreationTime) < '2026-06-17'
  324 +ORDER BY IFNULL(PrintedAt, CreationTime), Id;
  325 +```
  326 +
  327 +行数应与接口 `totalCount`(该日、该门店、权限范围内)一致。
  328 +
  329 +---
  330 +
  331 +## 关联文档
  332 +
  333 +- 三维 scope 业务规则:`项目相关文档/6-4代码优化.md`
  334 +- App Print Log Label ID:`项目相关文档/6-2代码优化.md`
  335 +- Preview 当日序号:`项目相关文档/6-11代码优化.md`
  336 +- 分页约定(SkipCount = 页码):`Helpers/PagedQueryConvention.cs`
... ...
项目相关文档/6-18代码优化.md 0 → 100644
  1 +# 6-18 代码优化
  2 +
  3 +本文档说明 **2026-06-18** 对 **`GET /api/app/label-template`** 列表接口的第二轮修复。
  4 +
  5 +6-17 已完成 scope 库结构兼容(未迁移 `fl_label_template_partner` / `fl_label_template_region` 时仍可查询),但测试环境 Web **Label Templates** 页仍返回 **500**,Network 中:
  6 +
  7 +`GET /api/app/label-template?SkipCount=1&MaxResultCount=10`
  8 +
  9 +测试环境:`http://flus-test.3ffoodsafety.com`
  10 +
  11 +---
  12 +
  13 +## 一、现象
  14 +
  15 +- 页面提示:**Failed to load label templates. Request failed.**
  16 +- 接口 HTTP **500**,响应体 `errors` 为空,无具体异常文案。
  17 +- 同页 `partner` / `group` / `location` 下拉接口可正常加载。
  18 +
  19 +> `SkipCount=1` 表示**第 1 页**(页码从 1 起),不是报错原因。详见 `Helpers/PagedQueryConvention.cs`。
  20 +
  21 +---
  22 +
  23 +## 二、根因
  24 +
  25 +服务端日志已明确报错:
  26 +
  27 +```text
  28 +Unknown column 'AppliedPartnerType' in 'field list'
  29 +```
  30 +
  31 +对应 SQL:
  32 +
  33 +```sql
  34 +SELECT ... `AppliedLocationType`,`AppliedPartnerType`,`AppliedRegionType`, ...
  35 +FROM `fl_label_template`
  36 +WHERE NOT ( `IsDeleted`=1 )
  37 +ORDER BY IFNULL(`LastModificationTime`,`CreationTime`) DESC
  38 +LIMIT 0,10
  39 +```
  40 +
  41 +### 2.1 主因:ORM 实体仍映射不存在列
  42 +
  43 +6-17 曾在 `FlLabelTemplateDbEntity` 上对 `AppliedPartnerType` / `AppliedRegionType` 标记 `[SugarColumn(IsIgnore = true)]`,但当前 SqlSugar 运行时**仍会**把这两列拼进 `SELECT`(与 `fl_label.AppliedRegionType` 的 `IsIgnore` 行为不一致),导致未执行 `fl_label_template_scope.sql` 的库直接 500。
  44 +
  45 +**MCP 查库(测试库)**:
  46 +
  47 +- `fl_label_template` **无** `AppliedPartnerType` / `AppliedRegionType` 列
  48 +- **无** `fl_label_template_partner` / `fl_label_template_region` 表
  49 +- **有** `AppliedLocationType` 与 `fl_label_template_location`
  50 +
  51 +### 2.2 次因:列表 SQL 其它隐患(一并修复)
  52 +
  53 +| # | 问题 | 后果 |
  54 +|---|------|------|
  55 +| 1 | 默认排序曾用 `LastModificationTime ?? CreationTime` | SqlSugar 翻译失败 → 500 |
  56 +| 2 | `Sorting` 直接 `OrderBy(input.Sorting)` 拼 SQL | 非法字段 / 注入风险 |
  57 +| 3 | 空 `templateIds` 仍 `Contains` 查询 | 可能生成 `IN ()` |
  58 +| 4 | 无效 partner/group 筛选未安全降级 | 未捕获异常 |
  59 +
  60 +---
  61 +
  62 +## 三、修复说明
  63 +
  64 +### 1. 从 ORM 实体移除不存在列 + 强制列投影(核心)
  65 +
  66 +| 改动 | 说明 |
  67 +|------|------|
  68 +| `FlLabelTemplateDbEntity` | **删除** `AppliedPartnerType` / `AppliedRegionType` 属性 |
  69 +| **`LabelTemplateQueryHelper.ProjectListColumns`** | 列表/详情/重复校验等只读查询 **显式 Select** 真实列,SQL 不再出现 scope 列 |
  70 +| `LabelTemplateScopeSchemaHelper` | 探测列/表;已迁移时用 raw SQL 写入 scope 列 |
  71 +| `LabelTemplateAppService` | `GetListAsync` 在排序后调用 `ProjectListColumns` |
  72 +
  73 +> **重要**:若 Yi-SQL 日志仍出现 `AppliedPartnerType`,说明进程加载的是**旧 DLL**(`FoodLabeling.Application` 未重新编译或未重启)。请先 `dotnet build` 通过后再**完全停止并重启** `Yi.Abp.Web`。
  74 +
  75 +### 2. 安全排序
  76 +
  77 +新增 `ApplyLabelTemplateListSorting`:
  78 +
  79 +- 默认:`ORDER BY IFNULL(LastModificationTime, CreationTime) DESC, TemplateCode ASC`
  80 +- `Sorting` 白名单字段 + asc/desc
  81 +
  82 +### 3. 列表查询健壮性
  83 +
  84 +| 改动 | 说明 |
  85 +|------|------|
  86 +| `input ??= new()`、`pageSize` 默认 10 | 入参/分页兜底 |
  87 +| `templateIds.Count > 0` 再查 elements/items | 避免空 `IN ()` |
  88 +| `ResolveFilteredLocationIdsForListAsync` | 无效 partner/group 返回空列表 |
  89 +| `DeleteAsync` | 仅当 scope 关联表存在时才删除 partner/region 行 |
  90 +
  91 +### 4. 与 6-17 的关系
  92 +
  93 +6-17 用 `IsIgnore` 试图跳过列映射,**实测无效**;6-18 改为**实体不含列 + raw SQL 按需写入**,与 `fl_label.AppliedRegionType` 处理方式一致。
  94 +
  95 +---
  96 +
  97 +## 四、接口说明
  98 +
  99 +| 项目 | 内容 |
  100 +|------|------|
  101 +| 方法 | `GET` |
  102 +| 路径 | `/api/app/label-template` |
  103 +| 鉴权 | Bearer Token(`Authorization: {data.token}`,`data.token` 已含 `Bearer ` 前缀) |
  104 +
  105 +### 入参(Query)
  106 +
  107 +| 参数 | 类型 | 必填 | 说明 |
  108 +|------|------|------|------|
  109 +| `SkipCount` | int | 否 | **页码,从 1 起**;第一页传 `1` |
  110 +| `MaxResultCount` | int | 否 | 每页条数;`<=0` 时后端按 **10** 处理 |
  111 +| `Keyword` | string | 否 | 模板名称 / 编码模糊搜索 |
  112 +| `PartnerId` | string | 否 | 按 Company(`fl_partner.Id`)筛选 |
  113 +| `GroupId` | string | 否 | 按 Region(`fl_group.Id`)筛选 |
  114 +| `LocationId` | string | 否 | 按门店(`location.Id`)筛选;**优先于** Partner/Region |
  115 +| `LabelType` | string | 否 | 如 `PRICE` / `NUTRITION` |
  116 +| `State` | bool | 否 | 启用状态 |
  117 +| `Sorting` | string | 否 | 白名单:`TemplateName asc/desc`、`TemplateCode asc/desc`、`CreationTime asc/desc`、`LastModificationTime asc/desc`;其它值忽略并走默认排序 |
  118 +
  119 +筛选解析顺序:**LocationId → GroupId → PartnerId**;均未传则不按门店范围收窄(仍返回全部未删除模板,除非前端传了无效 Id 则返回空列表)。
  120 +
  121 +### 出参(`PagedResultWithPageDto<LabelTemplateGetListOutputDto>`)
  122 +
  123 +| 字段 | 说明 |
  124 +|------|------|
  125 +| `pageIndex` | 当前页码 |
  126 +| `pageSize` | 每页条数 |
  127 +| `totalCount` | 总条数 |
  128 +| `totalPages` | 总页数 |
  129 +| `items[]` | 模板列表 |
  130 +
  131 +**`items[]` 主要字段**
  132 +
  133 +| 字段 | 说明 |
  134 +|------|------|
  135 +| `id` / `templateCode` | 模板编码(前端主键) |
  136 +| `templateName` | 模板名称 |
  137 +| `company` / `region` / `location` | 适用范围展示;未迁移 scope 库时 Company/Region 为 `All Companies` / `All Regions` |
  138 +| `partnerIds` / `regionIds` / `locationIds` | 对应 Id 数组 |
  139 +| `items` / `itemNames` | 模板内控件名称 |
  140 +| `contentsCount` | 控件数量 |
  141 +| `sizeText` | 如 `2x2inch` |
  142 +| `lastEdited` | 最近编辑时间(`LastModificationTime ?? CreationTime`) |
  143 +
  144 +---
  145 +
  146 +## 五、请求示例
  147 +
  148 +### 登录获取 Token
  149 +
  150 +```bash
  151 +curl -X POST "http://flus-test.3ffoodsafety.com/api/oauth/Login" \
  152 + -H "Content-Type: application/x-www-form-urlencoded" \
  153 + -d "userName=admin&password=123456"
  154 +```
  155 +
  156 +### 列表(第一页,默认排序)
  157 +
  158 +```bash
  159 +curl -G "http://flus-test.3ffoodsafety.com/api/app/label-template" \
  160 + -H "Authorization: Bearer {token}" \
  161 + --data-urlencode "SkipCount=1" \
  162 + --data-urlencode "MaxResultCount=10"
  163 +```
  164 +
  165 +### 带 Company 筛选
  166 +
  167 +```bash
  168 +curl -G "http://flus-test.3ffoodsafety.com/api/app/label-template" \
  169 + -H "Authorization: Bearer {token}" \
  170 + --data-urlencode "SkipCount=1" \
  171 + --data-urlencode "MaxResultCount=10" \
  172 + --data-urlencode "PartnerId={fl_partner.Id}"
  173 +```
  174 +
  175 +### 指定排序
  176 +
  177 +```bash
  178 +curl -G "http://flus-test.3ffoodsafety.com/api/app/label-template" \
  179 + -H "Authorization: Bearer {token}" \
  180 + --data-urlencode "SkipCount=1" \
  181 + --data-urlencode "MaxResultCount=10" \
  182 + --data-urlencode "Sorting=TemplateName asc"
  183 +```
  184 +
  185 +### 响应片段(示例)
  186 +
  187 +```json
  188 +{
  189 + "pageIndex": 1,
  190 + "pageSize": 10,
  191 + "totalCount": 3,
  192 + "totalPages": 1,
  193 + "items": [
  194 + {
  195 + "id": "tpl_n0b5h9_mpyssitm",
  196 + "templateCode": "tpl_n0b5h9_mpyssitm",
  197 + "templateName": "Retail Label w/Price Copy",
  198 + "company": "All Companies",
  199 + "region": "All Regions",
  200 + "location": "Ordos Airport, Store B",
  201 + "items": "Label Name, Price, Barcode",
  202 + "contentsCount": 5,
  203 + "sizeText": "2x2inch",
  204 + "lastEdited": "2026-06-04T08:30:00"
  205 + }
  206 + ]
  207 +}
  208 +```
  209 +
  210 +---
  211 +
  212 +## 六、验证步骤
  213 +
  214 +0. **重新编译并重启**(必做):
  215 + ```bash
  216 + cd "美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web"
  217 + dotnet build
  218 + ```
  219 + 停止正在运行的 Web 进程后重新启动;确认 Yi-SQL 中 **不再出现** `AppliedPartnerType`。
  220 +
  221 +1. **部署**包含 6-17 + 6-18 的后端并重启。
  222 +2. 调用 `GET /api/app/label-template?SkipCount=1&MaxResultCount=10` → 应 **200**,`totalCount >= 0`。
  223 +3. Web **Label Templates** 列表可加载,不再出现红色 **Failed to load label templates**。
  224 +4. 传无效 `PartnerId` / `GroupId` → **200** 且 `items=[]`(非 500)。
  225 +5. 不传 `Sorting` → 按最近编辑时间降序;传 `Sorting=TemplateName asc` → 按名称升序。
  226 +6. 分页:第 2 页 `SkipCount=2`,`totalCount` 与 UI 一致。
  227 +
  228 +### SQL 抽查(修复后 ORM 应生成的列)
  229 +
  230 +```sql
  231 +SELECT Id, TemplateCode, TemplateName, AppliedLocationType,
  232 + IFNULL(LastModificationTime, CreationTime) AS LastEdited
  233 +FROM fl_label_template
  234 +WHERE IsDeleted = 0
  235 +ORDER BY IFNULL(LastModificationTime, CreationTime) DESC, TemplateCode ASC
  236 +LIMIT 10;
  237 +```
  238 +
  239 +**不应再出现** `AppliedPartnerType` / `AppliedRegionType`。
  240 +
  241 +### 检查 scope 是否已迁移
  242 +
  243 +```sql
  244 +SELECT COUNT(*) AS scope_table_cnt
  245 +FROM information_schema.TABLES
  246 +WHERE TABLE_SCHEMA = DATABASE()
  247 + AND TABLE_NAME IN ('fl_label_template_partner', 'fl_label_template_region');
  248 +```
  249 +
  250 +`scope_table_cnt = 0` 时行为与 6-17 一致:仅 Location 维度落库与展示。
  251 +
  252 +---
  253 +
  254 +## 七、涉及代码
  255 +
  256 +| 文件 | 说明 |
  257 +|------|------|
  258 +| `Helpers/LabelTemplateQueryHelper.cs` | **新增** `ProjectListColumns` 强制 SQL 列白名单 |
  259 +| `Services/DbModels/FlLabelTemplateDbEntity.cs` | **移除** Partner/Region 列属性 |
  260 +| `Helpers/LabelTemplateScopeSchemaHelper.cs` | 列/表探测 + `SetAppliedScopeTypesAsync` raw SQL |
  261 +| `Services/LabelTemplateAppService.cs` | 安全排序、Create/Update 写 scope 列、列表健壮性 |
  262 +| `Helpers/LabelTemplateScopeHelper.cs` | 未迁移库 scope 过滤/展示(6-17) |
  263 +| `Helpers/LocationScopeBindingHelper.cs` | `ResolveFilteredLocationIdsForListAsync` |
  264 +| `Helpers/LabelTemplateListItemsHelper.cs` | Items 列 |
  265 +| `Dtos/LabelTemplate/LabelTemplateGetListInputVo.cs` | `PartnerId` 筛选 |
  266 +
  267 +---
  268 +
  269 +## 八、数据库迁移(可选)
  270 +
  271 +需完整 Company / Region 三维 scope 时,执行:
  272 +
  273 +`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_template_scope.sql`
  274 +
  275 +未执行前:**列表可正常返回**(6-17 + 6-18),但无法持久化 Company/Region 多选明细。
  276 +
  277 +---
  278 +
  279 +## 九、App 标签预览 `POST /api/app/us-app-labeling/preview`
  280 +
  281 +### 现象
  282 +
  283 +App 标签预览页调用 preview 失败(500 或 400),常与 **label-template 列表** 同源:加载模板头时 ORM 仍 SELECT 不存在的 `AppliedPartnerType` / `AppliedRegionType`。
  284 +
  285 +另有两项逻辑缺陷(与 `6-11` 文档不一致):
  286 +
  287 +| # | 问题 | 后果 |
  288 +|---|------|------|
  289 +| 1 | `LabelAppService.PreviewAsync` 全表查询 `FlLabelTemplateDbEntity` | 未迁移 scope 列时 **Unknown column** → 500 |
  290 +| 2 | `UsAppLabelingAppService.PreviewAsync` 未向 `_labelAppService.PreviewAsync` 传 **`locationId`** | 模板含 Company 自动生成元素时报 **「预览/打印需要 locationId 以填充 Company 信息」** |
  291 +| 3 | 出参 **`labelId`** 仍返回 `fl_label.Id`(GUID) | 与 Print Log 当日序号 `yyyyMMdd-n` 不一致 |
  292 +
  293 +### 修复说明
  294 +
  295 +| 改动 | 说明 |
  296 +|------|------|
  297 +| `LabelAppService.PreviewAsync` | 模板头查询改用 `LabelTemplateQueryHelper.ProjectListColumns` |
  298 +| `UsAppLabelingAppService.PreviewAsync` | 传入 `LocationId`;`labelId` 改用 `ReportsPrintLogDailyLabelIdHelper.ResolveNextDailyLabelIdAsync` |
  299 +| `UsAppLabelingAppService.PrintAsync` | 解析模板时同步传入 `LocationId`(打印与预览 Company 填充一致) |
  300 +| `DashboardAppService` | 模板统计 Count 同样走 `ProjectListColumns` |
  301 +
  302 +### 接口说明
  303 +
  304 +| 项目 | 内容 |
  305 +|------|------|
  306 +| 方法 | `POST` |
  307 +| 路径 | `/api/app/us-app-labeling/preview` |
  308 +| 鉴权 | App Bearer Token |
  309 +
  310 +#### 入参(Body:`UsAppLabelPreviewInputVo`)
  311 +
  312 +| 字段 | 类型 | 必填 | 说明 |
  313 +|------|------|------|------|
  314 +| `locationId` | string | 是 | 当前门店 Id(`location.Id`) |
  315 +| `labelCode` | string | 是 | 标签编码(`fl_label.LabelCode`) |
  316 +| `productId` | string | 否 | 预览产品 Id;不传则取标签绑定第一个产品 |
  317 +| `baseTime` | DateTime | 否 | 日期/时间控件基准;也用于计算当日 `labelId` 序号;未传为服务器当前时间 |
  318 +| `printInputJson` | object | 否 | `PRINT_INPUT` 元素用户输入 |
  319 +
  320 +#### 出参(`UsAppLabelPreviewDto`)
  321 +
  322 +| 字段 | 说明 |
  323 +|------|------|
  324 +| **`labelId`** | 门店当日**下一个**打印序号 `yyyyMMdd-n`(预览不落库) |
  325 +| `locationId` / `labelCode` | 回传入参 |
  326 +| `template` | 已解析 AUTO_DB / PRINT_INPUT 的模板结构(含 Company 自动填充) |
  327 +| `labelLastEdited` | 标签最近编辑时间 |
  328 +| 其它 | `typeName`、`productName`、`templateProductDefaultValues` 等 |
  329 +
  330 +#### `labelId` 规则
  331 +
  332 +与 `6-11`、`get-print-log-list` 一致:`{baseTime 日期 yyyyMMdd}-{当日已有打印任务数 + 1}`。
  333 +
  334 +#### Company 自动元素
  335 +
  336 +模板含 Company 自动生成控件时,后端根据 **`locationId`** 查 `fl_partner` 填充 `config.text`(详见 `6-11` 第二节)。
  337 +
  338 +### 请求示例
  339 +
  340 +```bash
  341 +curl -X POST "http://flus-test.3ffoodsafety.com/api/app/us-app-labeling/preview" \
  342 + -H "Authorization: Bearer {token}" \
  343 + -H "Content-Type: application/json" \
  344 + -d '{
  345 + "locationId": "550e8400-e29b-41d4-a716-446655440000",
  346 + "labelCode": "LBL0001",
  347 + "productId": "PROD001",
  348 + "baseTime": "2026-06-18T09:00:00"
  349 + }'
  350 +```
  351 +
  352 +### 响应片段(示例)
  353 +
  354 +```json
  355 +{
  356 + "labelId": "20260618-3",
  357 + "locationId": "550e8400-e29b-41d4-a716-446655440000",
  358 + "labelCode": "LBL0001",
  359 + "labelLastEdited": "2026-06-01T08:30:09",
  360 + "template": {
  361 + "id": "tpl_xxx",
  362 + "width": 2,
  363 + "height": 2,
  364 + "unit": "inch",
  365 + "elements": []
  366 + }
  367 +}
  368 +```
  369 +
  370 +### 验证步骤
  371 +
  372 +1. 停服 → `dotnet build` → 重启(同 label-template 一节)。
  373 +2. App 进入标签预览页,Network 中 preview 应 **200**。
  374 +3. 响应 `labelId` 为 `yyyyMMdd-n`,非 GUID。
  375 +4. 模板含 Company 元素时,`template.elements` 中对应 `config.text` 为门店所属公司名。
  376 +5. 当日已有 N 条打印任务时,preview 返回 `-{N+1}`。
  377 +
  378 +### 涉及代码(preview)
  379 +
  380 +| 文件 | 说明 |
  381 +|------|------|
  382 +| `Services/UsAppLabelingAppService.cs` | `PreviewAsync` / `PrintAsync` 传 `LocationId`、当日 `labelId` |
  383 +| `Services/LabelAppService.cs` | `PreviewAsync` 模板头 `ProjectListColumns` |
  384 +| `Helpers/LabelTemplateQueryHelper.cs` | 列投影 |
  385 +| `Helpers/ReportsPrintLogDailyLabelIdHelper.cs` | `ResolveNextDailyLabelIdAsync` |
  386 +| `Helpers/PartnerCompanyDisplayHelper.cs` | Company 自动填充 |
  387 +
  388 +---
  389 +
  390 +## 十、App 打印日志 `POST /api/app/us-app-labeling/get-print-log-list`
  391 +
  392 +### 现象
  393 +
  394 +Postman 传 `printDate: "2026-06-16"` 返回 `totalCount: 0`,误以为接口异常。
  395 +
  396 +### 根因(查库核对)
  397 +
  398 +门店 `3a218397-8dda-a378-e024-ef89bcef8d24` 在测试库中:
  399 +
  400 +| 自然日(`DATE(IFNULL(PrintedAt, CreationTime))`) | 记录数 |
  401 +|---------------------------------------------------|--------|
  402 +| `2026-06-01` | **7** |
  403 +| `2026-05-31` | **3** |
  404 +| `2026-06-16` | **0** |
  405 +
  406 +**接口按入参日期筛选时,该日无数据则返回空列表,行为正确。** 文档/示例误用 `2026-06-16`,应改用 **`2026-06-01`** 验证。
  407 +
  408 +另:`PrintedAt` 入库多为 `null`,筛选实际走 **`CreationTime`**。
  409 +
  410 +### 本轮修复
  411 +
  412 +| 改动 | 说明 |
  413 +|------|------|
  414 +| 日期条件 | 改用 MySQL `DATE(IFNULL(t.PrintedAt, t.CreationTime)) = 'yyyy-MM-dd'`,避免 DateTime 区间比较时区偏差 |
  415 +| `printDateDay` | 新增字符串入参(`yyyy-MM-dd`),优先于 `printDate`,避免 JSON 仅日期 UTC 歧义 |
  416 +| 未传日期 | **`printDate` 与 `printDateDay` 均未传时不按日过滤**(返回该门店全部打印记录,兼容 App 未传参) |
  417 +| `labelId` | 列表出参改为当日序号 `yyyyMMdd-n`;`labelEntityId` 为 `fl_label.Id` |
  418 +
  419 +### 请求示例(有数据的日期)
  420 +
  421 +```bash
  422 +curl -X POST "http://192.168.1.4:19001/api/app/us-app-labeling/get-print-log-list" \
  423 + -H "Authorization: Bearer {token}" \
  424 + -H "Content-Type: application/json" \
  425 + -d '{
  426 + "locationId": "3a218397-8dda-a378-e024-ef89bcef8d24",
  427 + "skipCount": 1,
  428 + "maxResultCount": 20,
  429 + "printDateDay": "2026-06-01"
  430 + }'
  431 +```
  432 +
  433 +或使用 `"printDate": "2026-06-01"`(等价)。
  434 +
  435 +### 权限说明
  436 +
  437 +非 admin / 非 Partner 角色时,仅返回 **`CreatedBy = 当前用户`** 的记录;若 Token 用户不是打印人,即使日期正确也会空列表。
  438 +
  439 +### 部署
  440 +
  441 +修改在 `FoodLabeling.Application`,需 **停服 → dotnet build → 重启** 后 Postman 才生效。
  442 +
  443 +### 涉及代码
  444 +
  445 +| 文件 | 说明 |
  446 +|------|------|
  447 +| `Helpers/ReportsPrintLogDailyLabelIdHelper.cs` | `ResolvePrintLogFilterCalendarDay`、`ApplyPrintTaskCalendarDayFilter` |
  448 +| `Dtos/UsAppLabeling/PrintLogGetListInputVo.cs` | `PrintDateDay` |
  449 +| `Services/UsAppLabelingAppService.cs` | `GetPrintLogListAsync` |
  450 +
  451 +---
  452 +
  453 +## 十一、角色编辑 `PUT /api/app/rbac-role/{id}` — accessPermissions
  454 +
  455 +### 现象
  456 +
  457 +编辑角色传 `"accessPermissions": "[\"manage_labels\"]"` 后权限未生效或菜单绑定被清空。
  458 +
  459 +### 根因
  460 +
  461 +前端/Web 约定 `accessPermissions` 为 **JSON 数组字符串**(见 `rbacRoleService.ts` 的 `JSON.stringify(codes)`),后端 `ParseAccessPermissionCodes` 原先仅按逗号拆分,整段 `["manage_labels"]` 被当成一个非法 token,`NormalizeAccessPermissionCodes` 过滤后为空 → **RoleMenu 被清空**。
  462 +
  463 +### 修复
  464 +
  465 +`RbacAccessPermissionHelper.ParseAccessPermissionCodes`:若字符串以 `[` 开头,先 `JsonSerializer.Deserialize<List<string>>`,再回退逗号分隔。
  466 +
  467 +### 合法 accessPermissions 值
  468 +
  469 +| 编码 | 说明 |
  470 +|------|------|
  471 +| `manage_labels` | 标签相关菜单(/labeling、/labels 等) |
  472 +| `manage_people` | 账号管理 |
  473 +| `edit_settings` | 菜单/多选项设置 |
  474 +| `view_reports` | 报表 |
  475 +| `manage_products` / `approve_batches` | 当前无独立菜单,不阻断其它权限 |
  476 +
  477 +### 请求示例
  478 +
  479 +```json
  480 +{
  481 + "roleName": "Staff",
  482 + "roleCode": "Staff",
  483 + "remark": null,
  484 + "dataScope": 0,
  485 + "state": true,
  486 + "orderNum": 0,
  487 + "accessPermissions": "[\"manage_labels\"]"
  488 +}
  489 +```
  490 +
  491 +亦可传 `"accessPermissionCodes": ["manage_labels"]`(与 `accessPermissions` 合并去重)。
  492 +
  493 +### 涉及代码
  494 +
  495 +| 文件 | 说明 |
  496 +|------|------|
  497 +| `Helpers/RbacAccessPermissionHelper.cs` | JSON 数组解析 |
  498 +| `Helpers/RoleAccessPermissionMenuMapping.cs` | UI 权限码 → Menu.Router |
  499 +| `Services/RbacRoleAppService.cs` | `UpdateAsync` / `ApplyRoleMenuBindingsAsync` |
  500 +
  501 +---
  502 +
  503 +## 十二、Team Member — Company Admin 范围绑定
  504 +
  505 +### 规则
  506 +
  507 +角色为 **Company Admin**(库内常为 **Partner Admin**,或 RoleCode 含 `partner`)时:
  508 +
  509 +| 字段 | 是否必填 | 说明 |
  510 +|------|----------|------|
  511 +| `partnerId` / `partnerIds` | **是** | 指定适用 Company |
  512 +| `regionIds` | **否** | 未传时后端自动绑定该公司下**全部 Region**(出参展示) |
  513 +| `locationIds` / `assignedLocations` | **否** | 未传时后端自动绑定该公司下**全部门店** 并写入 `userlocation` |
  514 +
  515 +其它角色仍须 Company / Region / 门店至少一项能解析出门店。
  516 +
  517 +### 请求示例(Create / Update)
  518 +
  519 +```json
  520 +{
  521 + "fullName": "Jane Doe",
  522 + "userName": "jane.doe",
  523 + "password": "123456",
  524 + "email": "jane@example.com",
  525 + "roleId": "{Partner Admin 角色 Guid}",
  526 + "partnerId": "{fl_partner.Id}",
  527 + "state": true
  528 +}
  529 +```
  530 +
  531 +无需传 `regionIds`、`locationIds`。
  532 +
  533 +### 列表出参
  534 +
  535 +`GET /api/app/team-member` 对 Company Admin 成员:
  536 +
  537 +- `roleName` 统一为 **Company Admin**(库内 Partner Admin 也会转换)
  538 +- `regionIds` 展示该公司下**全部 Region**
  539 +- `assignedLocations` 展示该公司下**全部门店**(无需前端再选手动 Region/Locations)
  540 +
  541 +### 编辑弹窗(Web)
  542 +
  543 +选 **Company Admin** 时隐藏 Region / Locations 字段,仅保留 **Company**;保存时只传 `partnerId`,后端自动展开绑定。
  544 +
  545 +### 涉及代码
  546 +
  547 +| 文件 | 说明 |
  548 +|------|------|
  549 +| `Helpers/TeamMemberRoleHelper.cs` | Company Admin 角色识别 |
  550 +| `Helpers/LocationScopeBindingHelper.cs` | `ResolveGroupIdsFromPartnerIdsAsync` |
  551 +| `Services/TeamMemberAppService.cs` | 保存/列表/详情 scope 逻辑 |
  552 +| `PeopleView.tsx` | 前端 Company Admin 放宽 Region/Locations 必填 |
  553 +
  554 +---
  555 +
  556 +## 十三、Team Member 批量导入 / PDF 导出 — Region 列
  557 +
  558 +### 模板列(下载 `download-team-member-import-template`)
  559 +
  560 +| 列 | 必填 | 说明 |
  561 +|----|------|------|
  562 +| Name / Email / Role Name | 是 | 与单条创建一致 |
  563 +| **Region** | **否** | 可留空;填 `fl_group.Id` 或 Region 名称(GroupName),多个用 `;` 分隔 |
  564 +| **Assigned Location Ids** | **否*** | 与 Region **至少填一项**;见下 |
  565 +
  566 +\* Company Admin 在 Web 端可只选 Company;Excel 批量导入仍须 Region 或门店至少一项(或后续扩展 Company 列)。
  567 +
  568 +### Assigned Location Ids 填什么?
  569 +
  570 +**优先填 `location.LocationCode`(门店编码)**,例如 `LOC001`、`33333`。
  571 +
  572 +也支持:
  573 +
  574 +- **`location.Id`(Guid 字符串)**
  575 +- **`编码 - 门店名`** 格式(取 ` -` 前一段作为编码或 Guid)
  576 +
  577 +模板示例 `LOC001;LOC002` 即为 **LocationCode**,不是 Guid。
  578 +
  579 +### 涉及代码
  580 +
  581 +| 文件 | 说明 |
  582 +|------|------|
  583 +| `Helpers/TeamMemberBatchExcelHelper.cs` | Region 列解析、`BuildImportTemplateWorkbook` |
  584 +| `Services/TeamMemberAppService.cs` | 导入解析 Region/Location、PDF 增 Region 列 |
  585 +
  586 +---
  587 +
  588 +## 十四、Team Member 列表只返回 1 条(应 3 条)
  589 +
  590 +### 现象
  591 +
  592 +`GET /api/app/team-member?SkipCount=1&MaxResultCount=10` 在 Subway China 下应返回 **123 / Sandi / Tom** 共 3 人,实际 `totalCount=1`。
  593 +
  594 +### 根因
  595 +
  596 +1. **Guid 字符串大小写不一致**:`userlocation.UserId` / `LocationId` 与 `User.Id.ToString()` 比较时用字符串 `Contains`,大小写不同会漏关联,导致成员 scope 命中失败、`assignedLocations` 为空。
  597 +2. **前端未传 `PartnerId`**:筛选 Company 时只靠客户端二次过滤,且 `locationIds` 为空时被误过滤。
  598 +3. **列表出参缺 `locationIds`**:前端无法正确做门店 scope 匹配。
  599 +
  600 +### 修复
  601 +
  602 +| 改动 | 说明 |
  603 +|------|------|
  604 +| `TeamMemberListScopeHelper` | 统一 `NormalizeScopeKey` / `UserKey`,用 **Guid** 比较 |
  605 +| `BuildFilteredUserQueryAsync` | 按 Guid 解析 scope 与 userlocation |
  606 +| `MapUsersToOutputAsync` | assignedMap 用规范化 userKey;出参增加 **`locationIds`** |
  607 +| 前端 `getTeamMembers` | 传 **`PartnerId` / `GroupId`** |
  608 +| `memberMatchesLocationScope` | 支持 **partnerIds** 匹配 + locationId 小写归一 |
  609 +
  610 +### 验证(测试库 Subway China)
  611 +
  612 +| 用户 | 应出现在 Subway 范围列表 |
  613 +|------|--------------------------|
  614 +| 123 / Sandi / Tom | 是 |
  615 +| Nancy Lang(GongCha) | 否 |
  616 +| admin | 仅平台管理员无筛选时可见 |
  617 +
  618 +---
  619 +
  620 +## 十五、Region(Group)列表 Company Admin 只返回 1 条
  621 +
  622 +### 现象
  623 +
  624 +`GET /api/app/group?SkipCount=1&MaxResultCount=10&Sorting=CreationTime+desc` 以 **Company Admin(Subway China / 用户 123)** 登录时,`totalCount=1`,仅 **Subway Beijing**;测试库 Subway China 下应有 **Beijing / Chendu / Shanghai** 共 3 条。
  625 +
  626 +### 根因
  627 +
  628 +`PartnerScopeHelper.ResolveGroupScopeAsync` 对非管理员按 **userlocation 绑定的门店** 推导 Region:只匹配 `location.Partner + location.GroupName` 对应的 `fl_group`。用户 123 仅绑定了 Beijing 门店,因此 scope 只有 **Subway Beijing** 一条,未展开到公司下全部 Region。
  629 +
  630 +### 修复
  631 +
  632 +| 文件 | 说明 |
  633 +|------|------|
  634 +| `Helpers/LocationScopeBindingHelper.cs` | 新增 `ResolveBoundLocationIdsForUserAsync`、`ResolveDisplayRegionIdsForUserAsync`(与 Team Member 详情 `regionIds` 同规则) |
  635 +| `Helpers/PartnerScopeHelper.cs` | `ResolveGroupScopeAsync` 改为调用上述方法;`ApplyGroupScope` 用 Guid 比较;`ResolvePartnerScopeAsync` 统一 Guid 读 `userlocation` |
  636 +
  637 +### 验证
  638 +
  639 +- 用户 **123**(Company Admin)→ `totalCount=3`,`items` 与编辑接口 `regionIds` 三条一致
  640 +- 平台 **admin** → 全部 4 条
  641 +- **SkipCount=1** 表示第一页(页码从 1 起),不是 offset
  642 +
  643 +---
  644 +
  645 +## 关联文档
  646 +
  647 +- App Preview labelId / Company:`项目相关文档/6-11代码优化.md`
  648 +- 第一轮 scope 兼容:`项目相关文档/6-17代码优化.md`(第一节、第二节 get-print-log-list)
  649 +- 三维 scope 业务规则:`项目相关文档/6-4代码优化.md`
  650 +- 分页约定:`Helpers/PagedQueryConvention.cs`
... ...
项目相关文档/Account-Management-批量导入模板/Team-Member-批量导入模板.csv
1   -Name,User Name (Login),Password,Email,Phone,Role Id,Role Name (仅说明勿删),Assigned Location Ids,Status
2   -John Doe,john.doe,ChangeMe123!,john@123.com,789654444,"(必填)从系统角色列表复制 Role Id","Staff","门店Guid1;门店Guid2",TRUE
  1 +Name,User Name,Password,Email,Phone,Role Id,Role Name,Region,Assigned Location Ids,Status
  2 +John Doe,john.doe,ChangeMe123!,john@example.com,789654444,,Staff,,LOC001;LOC002,TRUE
3 3 ,,,,,,,,,
... ...
项目相关文档/批量导入导出接口说明.md
... ... @@ -178,7 +178,7 @@
178 178 | 数据范围 | **全量**:符合筛选条件的全部成员;**不使用** `SkipCount` / `MaxResultCount` |
179 179 | 排序 | 有 `Sorting` 则按其排序,否则按创建时间降序 |
180 180 | 响应 | `Content-Type: application/pdf`,文件名示例 `team-members_yyyy-MM-dd_HH-mm-ss.pdf` |
181   -| PDF 列 | Name、Email、Phone、Role、Assigned Locations(多门店以分号拼接)、Status(Active/Inactive) |
  181 +| PDF 列 | Name、Email、Phone、Role、**Region**、Assigned Locations(多门店以分号拼接)、Status(Active/Inactive) |
182 182  
183 183 **说明**:PDF 中「Assigned Locations」展示该成员**全部**已分配门店(不受列表按门店筛选时「仅显示命中门店」的收缩影响),便于导出后审阅完整权限。
184 184  
... ... @@ -195,10 +195,14 @@
195 195  
196 196 **表头识别(摘要)**
197 197  
198   -- **必填列**:`Name`(或 FullName)、`Email`;**Role**;**Assigned Locations**(至少一条)。
199   -- **可选列**:`UserName` / `Login`(不填则登录账号用 Email)、`Password`(不填则用配置 **`TeamMemberImportDefaultPassword`**)、`Phone`、`Status`。
200   -- **Assigned Locations**:多个门店可用 **`;`**、`|`、换行、中文 **`,`** 分隔;支持 `33333 - Central Park Store`(取 **` -`** 前为门店编码或 Guid)。
201   -- **Role**:与系统 **`Role.RoleName`** 一致(忽略大小写与中间空格);未匹配则该行失败。
  198 +- **必填列**:`Name`(或 FullName)、`Email`;**Role Name**(或 **Role Id** Guid);**Region** 与 **Assigned Location Ids** **至少填一项**(均可留空仅适用于 Web 已支持的 Company Admin + Company 场景)。
  199 +- **可选列**:`User Name` / `Login`(不填则登录账号用 Email)、`Password`(不填则用配置 **`TeamMemberImportDefaultPassword`**)、`Phone`、`Status`、**`Region`**(可留空)。
  200 +- **Region**:多个可用 **`;`**、`|`, 换行分隔;支持 **`fl_group.Id`(Guid)** 或 **Region 名称(GroupName)**;仅填 Region 时会绑定该区域下全部门店。
  201 +- **Assigned Location Ids**:多个可用 **`;`**、`|`, 换行、中文 **`,`** 分隔;支持 **`location.LocationCode`(门店编码,推荐)** 或 **`location.Id`(Guid)**;亦支持 `LOC001 - Store Name`(取 **` -`** 前为编码或 Guid)。
  202 +- **Role**:与系统 **`Role.RoleName`** 一致(忽略大小写与中间空格);或填 **Role Id**(Guid)。
  203 +- 下载模板由服务端 **动态生成**(含 **Region** 列),不再依赖服务器目录中的静态 xlsx。
  204 +
  205 +**PDF 导出列**:Name、Email、Phone、Role、**Region**、Assigned Locations、Status。
202 206  
203 207 内部对每行调用与单条创建相同的业务逻辑;**单行失败不影响其它行**。
204 208  
... ...