Commit 28fa41e9ff6f3f7ddc78651c9e0123b24d7bd3b6
合并
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
项目相关文档/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 "http://flus-test.3ffoodsafety.com/api/app/us-app-labeling/preview" |
| 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 "http://flus-test.3ffoodsafety.com/api/app/us-app-labeling/preview" |
| 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 | ... | ... |