Commit 4d328ec29539558a2d3c3a161635276a63c9fc83

Authored by 李曜臣
1 parent 3b307558

平台端报表reports,仪表盘Dashboard统计缺失项补齐,合作伙伴partner;

app我的资料,门店信息
Showing 40 changed files with 3369 additions and 3 deletions
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Dashboard/DashboardOverviewOutputDto.cs
... ... @@ -41,6 +41,9 @@ public class DashboardOverviewOutputDto
41 41 /// <summary>按分类分布总数(前端直观命名)</summary>
42 42 public int ByCategoryTotal { get; set; }
43 43  
  44 + /// <summary>最近打印标签(全门店最新若干条,用于 Recent Labels 区块)</summary>
  45 + public List<DashboardRecentLabelItemDto> RecentLabels { get; set; } = new();
  46 +
44 47 /// <summary>统计时间</summary>
45 48 public DateTime GeneratedAt { get; set; }
46 49 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Dashboard/DashboardRecentLabelItemDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Dashboard;
  2 +
  3 +/// <summary>
  4 +/// Dashboard「Recent Labels」单行数据(最近打印记录)
  5 +/// </summary>
  6 +public class DashboardRecentLabelItemDto
  7 +{
  8 + /// <summary>打印任务 Id(fl_label_print_task.Id)</summary>
  9 + public string TaskId { get; set; } = string.Empty;
  10 +
  11 + /// <summary>标签编码(界面 Serial / Label ID,如 1-251201)</summary>
  12 + public string LabelCode { get; set; } = string.Empty;
  13 +
  14 + /// <summary>展示名称:优先产品名,否则标签名称</summary>
  15 + public string DisplayName { get; set; } = "无";
  16 +
  17 + /// <summary>打印人用户 Id(CreatedBy)</summary>
  18 + public string? PrintedByUserId { get; set; }
  19 +
  20 + /// <summary>打印人展示名(User.Name 或 UserName)</summary>
  21 + public string PrintedByName { get; set; } = "无";
  22 +
  23 + /// <summary>打印时间(PrintedAt 优先,否则 CreationTime)</summary>
  24 + public DateTime PrintedAt { get; set; }
  25 +
  26 + /// <summary>状态:<c>active</c> 或 <c>expired</c>(依据 PrintInputJson 中保质期与当前日期比较)</summary>
  27 + public string Status { get; set; } = "active";
  28 +
  29 + /// <summary>角标/尺寸短文案(如 2"x2",用于左侧圆标)</summary>
  30 + public string LabelTypeBadge { get; set; } = "无";
  31 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupCreateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Group;
  2 +
  3 +/// <summary>
  4 +/// 新建组织入参
  5 +/// </summary>
  6 +public class GroupCreateInputVo
  7 +{
  8 + public string GroupName { get; set; } = string.Empty;
  9 +
  10 + /// <summary>
  11 + /// 指派到的合作伙伴 Id(Assign to Partner)
  12 + /// </summary>
  13 + public string PartnerId { get; set; } = string.Empty;
  14 +
  15 + public bool State { get; set; } = true;
  16 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetListInputVo.cs 0 → 100644
  1 +using Volo.Abp.Application.Dtos;
  2 +
  3 +namespace FoodLabeling.Application.Contracts.Dtos.Group;
  4 +
  5 +/// <summary>
  6 +/// 组织(Group)分页查询入参
  7 +/// </summary>
  8 +public class GroupGetListInputVo : PagedAndSortedResultRequestDto
  9 +{
  10 + /// <summary>
  11 + /// 模糊搜索(GroupName、所属 Partner 的 PartnerName)
  12 + /// </summary>
  13 + public string? Keyword { get; set; }
  14 +
  15 + /// <summary>
  16 + /// 按所属合作伙伴筛选(fl_partner.Id)
  17 + /// </summary>
  18 + public string? PartnerId { get; set; }
  19 +
  20 + /// <summary>
  21 + /// 启用状态
  22 + /// </summary>
  23 + public bool? State { get; set; }
  24 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetListOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Group;
  2 +
  3 +/// <summary>
  4 +/// 组织列表项
  5 +/// </summary>
  6 +public class GroupGetListOutputDto
  7 +{
  8 + public string Id { get; set; } = string.Empty;
  9 +
  10 + public string GroupName { get; set; } = string.Empty;
  11 +
  12 + public string PartnerId { get; set; } = string.Empty;
  13 +
  14 + /// <summary>
  15 + /// 所属合作伙伴名称(列表「Parent Partner」列)
  16 + /// </summary>
  17 + public string PartnerName { get; set; } = string.Empty;
  18 +
  19 + public bool State { get; set; }
  20 +
  21 + public DateTime CreationTime { get; set; }
  22 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Group;
  2 +
  3 +/// <summary>
  4 +/// 组织详情
  5 +/// </summary>
  6 +public class GroupGetOutputDto
  7 +{
  8 + public string Id { get; set; } = string.Empty;
  9 +
  10 + public string GroupName { get; set; } = string.Empty;
  11 +
  12 + public string PartnerId { get; set; } = string.Empty;
  13 +
  14 + public string PartnerName { get; set; } = string.Empty;
  15 +
  16 + public bool State { get; set; }
  17 +
  18 + public DateTime CreationTime { get; set; }
  19 +
  20 + public DateTime? LastModificationTime { get; set; }
  21 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupUpdateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Group;
  2 +
  3 +/// <summary>
  4 +/// 编辑组织入参
  5 +/// </summary>
  6 +public class GroupUpdateInputVo
  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/Partner/PartnerCreateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Partner;
  2 +
  3 +/// <summary>
  4 +/// 新建合作伙伴入参
  5 +/// </summary>
  6 +public class PartnerCreateInputVo
  7 +{
  8 + public string PartnerName { get; set; } = string.Empty;
  9 +
  10 + public string? ContactEmail { get; set; }
  11 +
  12 + public string? PhoneNumber { get; set; }
  13 +
  14 + public bool State { get; set; } = true;
  15 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetListInputVo.cs 0 → 100644
  1 +using Volo.Abp.Application.Dtos;
  2 +
  3 +namespace FoodLabeling.Application.Contracts.Dtos.Partner;
  4 +
  5 +/// <summary>
  6 +/// 合作伙伴分页查询入参
  7 +/// </summary>
  8 +public class PartnerGetListInputVo : PagedAndSortedResultRequestDto
  9 +{
  10 + /// <summary>
  11 + /// 模糊搜索(PartnerName / ContactEmail / PhoneNumber)
  12 + /// </summary>
  13 + public string? Keyword { get; set; }
  14 +
  15 + /// <summary>
  16 + /// 启用状态(与列表筛选一致)
  17 + /// </summary>
  18 + public bool? State { get; set; }
  19 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetListOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Partner;
  2 +
  3 +/// <summary>
  4 +/// 合作伙伴列表项
  5 +/// </summary>
  6 +public class PartnerGetListOutputDto
  7 +{
  8 + public string Id { get; set; } = string.Empty;
  9 +
  10 + public string PartnerName { get; set; } = string.Empty;
  11 +
  12 + public string? ContactEmail { get; set; }
  13 +
  14 + public string? PhoneNumber { get; set; }
  15 +
  16 + public bool State { get; set; }
  17 +
  18 + public DateTime CreationTime { get; set; }
  19 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Partner;
  2 +
  3 +/// <summary>
  4 +/// 合作伙伴详情
  5 +/// </summary>
  6 +public class PartnerGetOutputDto
  7 +{
  8 + public string Id { get; set; } = string.Empty;
  9 +
  10 + public string PartnerName { get; set; } = string.Empty;
  11 +
  12 + public string? ContactEmail { get; set; }
  13 +
  14 + public string? PhoneNumber { get; set; }
  15 +
  16 + public bool State { get; set; }
  17 +
  18 + public DateTime CreationTime { get; set; }
  19 +
  20 + public DateTime? LastModificationTime { get; set; }
  21 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerUpdateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Partner;
  2 +
  3 +/// <summary>
  4 +/// 编辑合作伙伴入参
  5 +/// </summary>
  6 +public class PartnerUpdateInputVo
  7 +{
  8 + public string PartnerName { get; set; } = string.Empty;
  9 +
  10 + public string? ContactEmail { get; set; }
  11 +
  12 + public string? PhoneNumber { get; set; }
  13 +
  14 + public bool State { get; set; } = true;
  15 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsLabelReportOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Reports;
  2 +
  3 +/// <summary>
  4 +/// Label Report 聚合结果(卡片 + 图表 + Top 产品表)
  5 +/// </summary>
  6 +public class ReportsLabelReportOutputDto
  7 +{
  8 + public ReportsLabelReportSummaryDto Summary { get; set; } = new();
  9 +
  10 + /// <summary>按标签分类的打印量(柱状图)</summary>
  11 + public List<ReportsCategoryCountDto> LabelsByCategory { get; set; } = new();
  12 +
  13 + /// <summary>折线图:默认统计区间内最后 7 个自然日(按日汇总)</summary>
  14 + public List<ReportsDailyCountDto> PrintVolumeTrend { get; set; } = new();
  15 +
  16 + /// <summary>用量最高的产品(含占比)</summary>
  17 + public List<ReportsTopProductRowDto> MostUsedProducts { get; set; } = new();
  18 +}
  19 +
  20 +public class ReportsLabelReportSummaryDto
  21 +{
  22 + public int TotalLabelsPrinted { get; set; }
  23 +
  24 + public int TotalLabelsPrintedPrevPeriod { get; set; }
  25 +
  26 + /// <summary>相对上一同长周期变化率(百分比,如 20.1 表示 +20.1%)</summary>
  27 + public decimal TotalLabelsPrintedChangeRate { get; set; }
  28 +
  29 + public string MostPrintedCategoryName { get; set; } = "无";
  30 +
  31 + public int MostPrintedCategoryCount { get; set; }
  32 +
  33 + public string TopProductName { get; set; } = "无";
  34 +
  35 + public int TopProductCount { get; set; }
  36 +
  37 + public decimal AvgDailyPrints { get; set; }
  38 +
  39 + public decimal AvgDailyPrintsPrevPeriod { get; set; }
  40 +
  41 + public decimal AvgDailyPrintsChangeRate { get; set; }
  42 +}
  43 +
  44 +public class ReportsCategoryCountDto
  45 +{
  46 + public string CategoryId { get; set; } = string.Empty;
  47 +
  48 + public string CategoryName { get; set; } = "无";
  49 +
  50 + public int Count { get; set; }
  51 +}
  52 +
  53 +public class ReportsDailyCountDto
  54 +{
  55 + public string Date { get; set; } = string.Empty;
  56 +
  57 + public int Count { get; set; }
  58 +}
  59 +
  60 +public class ReportsTopProductRowDto
  61 +{
  62 + public string ProductId { get; set; } = string.Empty;
  63 +
  64 + public string ProductName { get; set; } = "无";
  65 +
  66 + public string CategoryName { get; set; } = "无";
  67 +
  68 + public int TotalPrinted { get; set; }
  69 +
  70 + public decimal UsagePercent { get; set; }
  71 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsLabelReportQueryInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Reports;
  2 +
  3 +/// <summary>
  4 +/// Label Report 统计与导出共用筛选
  5 +/// </summary>
  6 +public class ReportsLabelReportQueryInputVo
  7 +{
  8 + public string? PartnerId { get; set; }
  9 +
  10 + public string? GroupId { get; set; }
  11 +
  12 + public string? LocationId { get; set; }
  13 +
  14 + public DateTime? StartDate { get; set; }
  15 +
  16 + public DateTime? EndDate { get; set; }
  17 +
  18 + public string? Keyword { get; set; }
  19 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsPrintLogGetListInputVo.cs 0 → 100644
  1 +using Volo.Abp.Application.Dtos;
  2 +
  3 +namespace FoodLabeling.Application.Contracts.Dtos.Reports;
  4 +
  5 +/// <summary>
  6 +/// Web Reports — Print Log 分页查询
  7 +/// </summary>
  8 +public class ReportsPrintLogGetListInputVo : PagedAndSortedResultRequestDto
  9 +{
  10 + public string? PartnerId { get; set; }
  11 +
  12 + public string? GroupId { get; set; }
  13 +
  14 + public string? LocationId { get; set; }
  15 +
  16 + public DateTime? StartDate { get; set; }
  17 +
  18 + public DateTime? EndDate { get; set; }
  19 +
  20 + public string? Keyword { get; set; }
  21 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsPrintLogListItemDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Reports;
  2 +
  3 +/// <summary>
  4 +/// Print Log 列表行(Reports 管理端)
  5 +/// </summary>
  6 +public class ReportsPrintLogListItemDto
  7 +{
  8 + /// <summary>打印任务 Id(fl_label_print_task.Id),重打时使用</summary>
  9 + public string TaskId { get; set; } = string.Empty;
  10 +
  11 + /// <summary>标签编码(展示为 Label ID)</summary>
  12 + public string LabelCode { get; set; } = string.Empty;
  13 +
  14 + public string ProductName { get; set; } = "无";
  15 +
  16 + /// <summary>分类展示名(优先产品分类,否则标签分类)</summary>
  17 + public string CategoryName { get; set; } = "无";
  18 +
  19 + /// <summary>模板展示(尺寸 + 模板名)</summary>
  20 + public string TemplateText { get; set; } = "无";
  21 +
  22 + public DateTime PrintedAt { get; set; }
  23 +
  24 + /// <summary>打印人姓名</summary>
  25 + public string PrintedByName { get; set; } = "无";
  26 +
  27 + /// <summary>门店展示:名称 (编码)</summary>
  28 + public string LocationText { get; set; } = "无";
  29 +
  30 + /// <summary>门店 Id(重打校验用)</summary>
  31 + public string? LocationId { get; set; }
  32 +
  33 + /// <summary>从 PrintInputJson 尽力解析的保质期文本;无则「无」</summary>
  34 + public string ExpiryDateText { get; set; } = "无";
  35 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppChangePasswordInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
  2 +
  3 +/// <summary>
  4 +/// App 修改密码入参
  5 +/// </summary>
  6 +public class UsAppChangePasswordInputVo
  7 +{
  8 + /// <summary>当前密码</summary>
  9 + public string CurrentPassword { get; set; } = string.Empty;
  10 +
  11 + /// <summary>新密码</summary>
  12 + public string NewPassword { get; set; } = string.Empty;
  13 +
  14 + /// <summary>确认新密码</summary>
  15 + public string ConfirmNewPassword { get; set; } = string.Empty;
  16 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLocationDetailOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
  2 +
  3 +/// <summary>
  4 +/// App「Location」门店详情(与原型字段对齐)
  5 +/// </summary>
  6 +public class UsAppLocationDetailOutputDto
  7 +{
  8 + /// <summary>门店主键(Guid 字符串)</summary>
  9 + public string LocationId { get; set; } = string.Empty;
  10 +
  11 + /// <summary>门店名称</summary>
  12 + public string LocationName { get; set; } = string.Empty;
  13 +
  14 + /// <summary>完整地址(街道、城市、州、邮编拼接;无则为「无」)</summary>
  15 + public string FullAddress { get; set; } = string.Empty;
  16 +
  17 + /// <summary>门店电话(来自 location.Phone;空为「无」)</summary>
  18 + public string StorePhone { get; set; } = string.Empty;
  19 +
  20 + /// <summary>营业时间;当前库无字段,固定返回「无」直至业务落库</summary>
  21 + public string OperatingHours { get; set; } = string.Empty;
  22 +
  23 + /// <summary>店长姓名;优先取绑定本店且角色名/编码含 manager 的用户</summary>
  24 + public string ManagerName { get; set; } = string.Empty;
  25 +
  26 + /// <summary>店长电话;同上用户 User.Phone 格式化;无则为「无」</summary>
  27 + public string ManagerPhone { get; set; } = string.Empty;
  28 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppMyProfileOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
  2 +
  3 +/// <summary>
  4 +/// App「我的资料」展示数据(My Profile)
  5 +/// </summary>
  6 +public class UsAppMyProfileOutputDto
  7 +{
  8 + /// <summary>全名(Name,无则 Nick,再无则 UserName)</summary>
  9 + public string FullName { get; set; } = string.Empty;
  10 +
  11 + /// <summary>邮箱</summary>
  12 + public string Email { get; set; } = "无";
  13 +
  14 + /// <summary>电话展示(如 +1 (555) 123-4567);无则「无」</summary>
  15 + public string Phone { get; set; } = "无";
  16 +
  17 + /// <summary>员工号/登录名(当前使用 <c>User.UserName</c>,可与业务约定为工号)</summary>
  18 + public string EmployeeId { get; set; } = string.Empty;
  19 +
  20 + /// <summary>角色展示名(多角色英文逗号拼接,按角色 OrderNum)</summary>
  21 + public string RoleDisplay { get; set; } = "无";
  22 +
  23 + /// <summary>主角色编码(第一个角色 RoleCode,供前端样式)</summary>
  24 + public string? PrimaryRoleCode { get; set; }
  25 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IDashboardAppService.cs
... ... @@ -9,7 +9,7 @@ namespace FoodLabeling.Application.Contracts.IServices;
9 9 public interface IDashboardAppService : IApplicationService
10 10 {
11 11 /// <summary>
12   - /// 获取 Dashboard 总览统计(卡片 + 周趋势 + 分类分布
  12 + /// 获取 Dashboard 总览统计(卡片 + 周趋势 + 分类分布 + Recent Labels 最近打印
13 13 /// </summary>
14 14 Task<DashboardOverviewOutputDto> GetOverviewAsync();
15 15 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IGroupAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +using FoodLabeling.Application.Contracts.Dtos.Group;
  3 +using Microsoft.AspNetCore.Mvc;
  4 +using Volo.Abp.Application.Dtos;
  5 +using Volo.Abp.Application.Services;
  6 +
  7 +namespace FoodLabeling.Application.Contracts.IServices;
  8 +
  9 +/// <summary>
  10 +/// 组织(Group)管理接口(fl_group)
  11 +/// </summary>
  12 +public interface IGroupAppService : IApplicationService
  13 +{
  14 + /// <summary>
  15 + /// 组织分页列表(与导出使用相同筛选条件)
  16 + /// </summary>
  17 + /// <param name="input">分页与筛选;SkipCount 为页码(从 1 起)</param>
  18 + Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input);
  19 +
  20 + /// <summary>
  21 + /// 组织详情
  22 + /// </summary>
  23 + Task<GroupGetOutputDto> GetAsync(string id);
  24 +
  25 + /// <summary>
  26 + /// 新增组织
  27 + /// </summary>
  28 + Task<GroupGetOutputDto> CreateAsync(GroupCreateInputVo input);
  29 +
  30 + /// <summary>
  31 + /// 编辑组织
  32 + /// </summary>
  33 + Task<GroupGetOutputDto> UpdateAsync(string id, GroupUpdateInputVo input);
  34 +
  35 + /// <summary>
  36 + /// 删除组织(逻辑删除)
  37 + /// </summary>
  38 + Task DeleteAsync(string id);
  39 +
  40 + /// <summary>
  41 + /// 按列表相同筛选条件导出组织为 PDF(上限 5000 条)
  42 + /// </summary>
  43 + Task<IActionResult> ExportPdfAsync(GroupGetListInputVo input);
  44 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IPartnerAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +using FoodLabeling.Application.Contracts.Dtos.Partner;
  3 +using Microsoft.AspNetCore.Mvc;
  4 +using Volo.Abp.Application.Dtos;
  5 +using Volo.Abp.Application.Services;
  6 +
  7 +namespace FoodLabeling.Application.Contracts.IServices;
  8 +
  9 +/// <summary>
  10 +/// 合作伙伴管理接口(fl_partner)
  11 +/// </summary>
  12 +public interface IPartnerAppService : IApplicationService
  13 +{
  14 + /// <summary>
  15 + /// 合作伙伴分页列表(与导出使用相同筛选条件)
  16 + /// </summary>
  17 + /// <param name="input">分页与筛选;SkipCount 为页码(从 1 起)</param>
  18 + /// <returns>分页数据</returns>
  19 + /// <response code="200">成功</response>
  20 + /// <response code="400">参数错误</response>
  21 + /// <response code="500">服务器错误</response>
  22 + Task<PagedResultWithPageDto<PartnerGetListOutputDto>> GetListAsync(PartnerGetListInputVo input);
  23 +
  24 + /// <summary>
  25 + /// 合作伙伴详情
  26 + /// </summary>
  27 + /// <param name="id">主键 Id</param>
  28 + /// <returns>详情</returns>
  29 + /// <response code="200">成功</response>
  30 + /// <response code="400">Id 无效</response>
  31 + /// <response code="500">服务器错误</response>
  32 + Task<PartnerGetOutputDto> GetAsync(string id);
  33 +
  34 + /// <summary>
  35 + /// 新增合作伙伴
  36 + /// </summary>
  37 + /// <param name="input">名称、邮箱、电话、启用状态</param>
  38 + /// <returns>新建后的详情</returns>
  39 + /// <remarks>
  40 + /// 示例请求:
  41 + /// ```json
  42 + /// {
  43 + /// "partnerName": "Global Foods Inc.",
  44 + /// "contactEmail": "admin@globalfoods.com",
  45 + /// "phoneNumber": "+1 (555) 100-2000",
  46 + /// "state": true
  47 + /// }
  48 + /// ```
  49 + /// </remarks>
  50 + /// <response code="200">成功</response>
  51 + /// <response code="400">校验失败</response>
  52 + /// <response code="500">服务器错误</response>
  53 + Task<PartnerGetOutputDto> CreateAsync(PartnerCreateInputVo input);
  54 +
  55 + /// <summary>
  56 + /// 编辑合作伙伴
  57 + /// </summary>
  58 + /// <param name="id">主键 Id</param>
  59 + /// <param name="input">名称、邮箱、电话、启用状态</param>
  60 + /// <returns>更新后的详情</returns>
  61 + /// <response code="200">成功</response>
  62 + /// <response code="400">校验失败或记录不存在</response>
  63 + /// <response code="500">服务器错误</response>
  64 + Task<PartnerGetOutputDto> UpdateAsync(string id, PartnerUpdateInputVo input);
  65 +
  66 + /// <summary>
  67 + /// 删除合作伙伴(逻辑删除)
  68 + /// </summary>
  69 + /// <param name="id">主键 Id</param>
  70 + /// <response code="200">成功</response>
  71 + /// <response code="400">Id 无效或记录不存在</response>
  72 + /// <response code="500">服务器错误</response>
  73 + Task DeleteAsync(string id);
  74 +
  75 + /// <summary>
  76 + /// 按当前列表筛选条件批量导出合作伙伴为 PDF(不分页,上限 5000 条)
  77 + /// </summary>
  78 + /// <param name="input">与列表相同的 Keyword、State;分页字段忽略</param>
  79 + /// <returns>PDF 文件流</returns>
  80 + /// <remarks>
  81 + /// 筛选条件需与 <see cref="GetListAsync"/> 一致,便于统计与导出数据对齐。
  82 + /// </remarks>
  83 + /// <response code="200">成功返回 application/pdf</response>
  84 + /// <response code="400">参数错误</response>
  85 + /// <response code="500">服务器错误</response>
  86 + Task<IActionResult> ExportPdfAsync(PartnerGetListInputVo input);
  87 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IReportsAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +using FoodLabeling.Application.Contracts.Dtos.Reports;
  3 +using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
  4 +using Microsoft.AspNetCore.Mvc;
  5 +using Volo.Abp.Application.Services;
  6 +
  7 +namespace FoodLabeling.Application.Contracts.IServices;
  8 +
  9 +/// <summary>
  10 +/// Reports(Print Log / Label Report)管理端接口
  11 +/// </summary>
  12 +public interface IReportsAppService : IApplicationService
  13 +{
  14 + /// <summary>
  15 + /// Print Log 分页列表;角色 <c>admin</c> 可查全部,否则仅当前用户打印记录。
  16 + /// </summary>
  17 + Task<PagedResultWithPageDto<ReportsPrintLogListItemDto>> GetPrintLogListAsync(ReportsPrintLogGetListInputVo input);
  18 +
  19 + /// <summary>
  20 + /// Print Log 导出 PDF(筛选与列表一致,最多 5000 条)
  21 + /// </summary>
  22 + Task<IActionResult> ExportPrintLogPdfAsync(ReportsPrintLogGetListInputVo input);
  23 +
  24 + /// <summary>
  25 + /// 根据历史任务重打(与 App 入参一致);<c>admin</c> 可重打任意用户任务,否则仅本人任务。
  26 + /// </summary>
  27 + Task<UsAppLabelPrintOutputDto> ReprintPrintLogAsync(UsAppLabelReprintInputVo input);
  28 +
  29 + /// <summary>
  30 + /// Label Report 统计(卡片 + 分类柱数据 + 7 日趋势 + Top 产品);<c>admin</c> 统计全部,否则仅当前用户。
  31 + /// </summary>
  32 + Task<ReportsLabelReportOutputDto> GetLabelReportAsync(ReportsLabelReportQueryInputVo input);
  33 +
  34 + /// <summary>
  35 + /// Label Report 导出 PDF
  36 + /// </summary>
  37 + Task<IActionResult> ExportLabelReportPdfAsync(ReportsLabelReportQueryInputVo input);
  38 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs
... ... @@ -17,4 +17,35 @@ public interface IUsAppAuthAppService : IApplicationService
17 17 /// 获取当前登录账号已绑定的门店(用于切换门店等场景)
18 18 /// </summary>
19 19 Task<List<UsAppBoundLocationDto>> GetMyLocationsAsync();
  20 +
  21 + /// <summary>
  22 + /// 获取当前登录用户资料(My Profile:姓名、邮箱、电话、员工号、角色)
  23 + /// </summary>
  24 + /// <returns>资料 DTO</returns>
  25 + /// <response code="200">成功</response>
  26 + /// <response code="400">未登录或用户不存在</response>
  27 + /// <response code="500">服务器错误</response>
  28 + Task<UsAppMyProfileOutputDto> GetMyProfileAsync();
  29 +
  30 + /// <summary>
  31 + /// 当前登录用户修改密码(校验原密码与复杂度规则)
  32 + /// </summary>
  33 + /// <param name="input">当前密码、新密码、确认密码</param>
  34 + /// <remarks>
  35 + /// 新密码需满足:至少 8 位;含大写与小写字母;至少 1 位数字;至少 1 个非字母数字特殊字符。
  36 + /// </remarks>
  37 + /// <response code="200">成功</response>
  38 + /// <response code="400">参数或校验失败</response>
  39 + /// <response code="500">服务器错误</response>
  40 + Task ChangePasswordAsync(UsAppChangePasswordInputVo input);
  41 +
  42 + /// <summary>
  43 + /// 按门店 Id 查询 Location 详情(须为当前账号 userlocation 绑定门店)
  44 + /// </summary>
  45 + /// <param name="locationId">门店 Guid 字符串</param>
  46 + /// <returns>店名、地址、电话、营业时间占位、店长信息</returns>
  47 + /// <response code="200">成功</response>
  48 + /// <response code="400">参数非法、未绑定或无权限</response>
  49 + /// <response code="500">服务器错误</response>
  50 + Task<UsAppLocationDetailOutputDto> GetLocationDetailAsync(string locationId);
20 51 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj
... ... @@ -3,6 +3,7 @@
3 3  
4 4 <ItemGroup>
5 5 <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" />
  6 + <PackageReference Include="QuestPDF" Version="2024.12.2" />
6 7 </ItemGroup>
7 8  
8 9 <ItemGroup>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs 0 → 100644
  1 +using Volo.Abp.Users;
  2 +
  3 +namespace FoodLabeling.Application.Helpers;
  4 +
  5 +/// <summary>
  6 +/// Reports 模块角色判断(与 JWT / CurrentUser.Roles 中的角色码一致)
  7 +/// </summary>
  8 +public static class ReportsRoleHelper
  9 +{
  10 + /// <summary>
  11 + /// 是否为管理员:任一角色码等于 <c>admin</c>(忽略大小写)则视为可查看全部打印数据。
  12 + /// </summary>
  13 + public static bool IsAdminRole(ICurrentUser currentUser)
  14 + {
  15 + if (currentUser.Roles is null)
  16 + {
  17 + return false;
  18 + }
  19 +
  20 + foreach (var r in currentUser.Roles)
  21 + {
  22 + if (!string.IsNullOrWhiteSpace(r) &&
  23 + string.Equals(r.Trim(), "admin", StringComparison.OrdinalIgnoreCase))
  24 + {
  25 + return true;
  26 + }
  27 + }
  28 +
  29 + return false;
  30 + }
  31 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs
  1 +using System.Globalization;
  2 +using System.Text.Json;
1 3 using FoodLabeling.Application.Contracts.Dtos.Dashboard;
2 4 using FoodLabeling.Application.Contracts.IServices;
3 5 using FoodLabeling.Application.Services.DbModels;
4 6 using FoodLabeling.Domain.Entities;
  7 +using SqlSugar;
5 8 using Volo.Abp.Application.Services;
6 9 using Yi.Framework.Rbac.Domain.Entities;
7 10 using Yi.Framework.SqlSugarCore.Abstractions;
... ... @@ -127,6 +130,72 @@ public class DashboardAppService : ApplicationService, IDashboardAppService
127 130 .ThenBy(x => x.CategoryName)
128 131 .ToList();
129 132  
  133 + const int recentLabelsTake = 10;
  134 + var recentRaw = await _dbContext.SqlSugarClient.Queryable<FlLabelPrintTaskDbEntity>()
  135 + .LeftJoin<FlLabelDbEntity>((t, l) => t.LabelId == l.Id)
  136 + .LeftJoin<FlProductDbEntity>((t, l, p) => t.ProductId == p.Id)
  137 + .LeftJoin<FlLabelTemplateDbEntity>((t, l, p, tpl) => t.TemplateId == tpl.Id)
  138 + .OrderBy((t, l, p, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime), OrderByType.Desc)
  139 + .Take(recentLabelsTake)
  140 + .Select((t, l, p, tpl) => new
  141 + {
  142 + t.Id,
  143 + LabelCode = l.LabelCode,
  144 + LabelName = l.LabelName,
  145 + ProductName = p.ProductName,
  146 + tpl.Width,
  147 + tpl.Height,
  148 + tpl.Unit,
  149 + t.PrintInputJson,
  150 + PrintedAt = SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
  151 + t.CreatedBy
  152 + })
  153 + .ToListAsync();
  154 +
  155 + var recentUserIds = recentRaw
  156 + .Select(x => x.CreatedBy)
  157 + .Where(x => !string.IsNullOrWhiteSpace(x))
  158 + .Select(x => x!.Trim())
  159 + .Distinct()
  160 + .Select(x => Guid.TryParse(x, out var g) ? g : (Guid?)null)
  161 + .Where(x => x.HasValue)
  162 + .Select(x => x!.Value)
  163 + .ToList();
  164 +
  165 + var recentUserMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  166 + if (recentUserIds.Count > 0)
  167 + {
  168 + var users = await _userRepository._DbQueryable
  169 + .Where(u => !u.IsDeleted && recentUserIds.Contains(u.Id))
  170 + .Select(u => new { u.Id, u.Name, u.UserName })
  171 + .ToListAsync();
  172 + foreach (var u in users)
  173 + {
  174 + var display = !string.IsNullOrWhiteSpace(u.Name) ? u.Name.Trim() : u.UserName.Trim();
  175 + recentUserMap[u.Id.ToString()] = string.IsNullOrWhiteSpace(display) ? "无" : display;
  176 + }
  177 + }
  178 +
  179 + var recentLabels = recentRaw.Select(x =>
  180 + {
  181 + var displayName = !string.IsNullOrWhiteSpace(x.ProductName)
  182 + ? x.ProductName.Trim()
  183 + : (string.IsNullOrWhiteSpace(x.LabelName) ? "无" : x.LabelName.Trim());
  184 + var printedAt = x.PrintedAt ?? DateTime.MinValue;
  185 + var status = ResolveRecentLabelStatus(x.PrintInputJson);
  186 + return new DashboardRecentLabelItemDto
  187 + {
  188 + TaskId = x.Id,
  189 + LabelCode = string.IsNullOrWhiteSpace(x.LabelCode) ? "无" : x.LabelCode.Trim(),
  190 + DisplayName = displayName,
  191 + PrintedByUserId = x.CreatedBy?.Trim(),
  192 + PrintedByName = ResolveRecentUserName(recentUserMap, x.CreatedBy),
  193 + PrintedAt = printedAt,
  194 + Status = status,
  195 + LabelTypeBadge = FormatTemplateBadge(x.Width, x.Height, x.Unit)
  196 + };
  197 + }).ToList();
  198 +
130 199 var labelsPrintedTodayCard = BuildMetricCard("labelsPrintedToday", "Labels Printed Today", printedToday, printedYesterday);
131 200 var activeTemplatesCard = BuildMetricCard("activeTemplates", "Active Templates", activeTemplates, activeTemplatesPrevWeek);
132 201 var activeUsersCard = BuildMetricCard("activeUsers", "Active Users", activeUsers, activeUsersPrevWeek);
... ... @@ -156,12 +225,100 @@ public class DashboardAppService : ApplicationService, IDashboardAppService
156 225 CategoryDistributionTotal = categoryDistributionTotal,
157 226 ByCategory = categoryDistribution,
158 227 ByCategoryTotal = categoryDistributionTotal,
  228 + RecentLabels = recentLabels,
159 229 GeneratedAt = now
160 230 };
161 231  
162 232 return output;
163 233 }
164 234  
  235 + private static string ResolveRecentUserName(Dictionary<string, string> map, string? createdBy)
  236 + {
  237 + if (string.IsNullOrWhiteSpace(createdBy))
  238 + {
  239 + return "无";
  240 + }
  241 +
  242 + return map.TryGetValue(createdBy.Trim(), out var n) ? n : "无";
  243 + }
  244 +
  245 + /// <summary>
  246 + /// 依据 PrintInputJson 中的保质期字段与「当前日期」比较得到 active/expired。
  247 + /// </summary>
  248 + private static string ResolveRecentLabelStatus(string? printInputJson)
  249 + {
  250 + if (!TryParseExpiryDate(printInputJson, out var expiryDate))
  251 + {
  252 + return "active";
  253 + }
  254 +
  255 + var today = DateTime.Now.Date;
  256 + return expiryDate.Date < today ? "expired" : "active";
  257 + }
  258 +
  259 + private static bool TryParseExpiryDate(string? printInputJson, out DateTime expiryDate)
  260 + {
  261 + expiryDate = default;
  262 + if (string.IsNullOrWhiteSpace(printInputJson))
  263 + {
  264 + return false;
  265 + }
  266 +
  267 + try
  268 + {
  269 + using var doc = JsonDocument.Parse(printInputJson);
  270 + if (doc.RootElement.ValueKind != JsonValueKind.Object)
  271 + {
  272 + return false;
  273 + }
  274 +
  275 + foreach (var prop in doc.RootElement.EnumerateObject())
  276 + {
  277 + var key = prop.Name.Trim();
  278 + if (!key.Equals("expiryDate", StringComparison.OrdinalIgnoreCase) &&
  279 + !key.Equals("expiry", StringComparison.OrdinalIgnoreCase) &&
  280 + !key.Equals("expirationDate", StringComparison.OrdinalIgnoreCase))
  281 + {
  282 + continue;
  283 + }
  284 +
  285 + var v = prop.Value;
  286 + if (v.ValueKind == JsonValueKind.String)
  287 + {
  288 + var s = v.GetString();
  289 + if (!string.IsNullOrWhiteSpace(s) &&
  290 + DateTime.TryParse(s, CultureInfo.InvariantCulture,
  291 + DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dt))
  292 + {
  293 + expiryDate = dt;
  294 + return true;
  295 + }
  296 + }
  297 + else if (v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var unix))
  298 + {
  299 + expiryDate = DateTimeOffset.FromUnixTimeSeconds(unix).LocalDateTime;
  300 + return true;
  301 + }
  302 + }
  303 + }
  304 + catch
  305 + {
  306 + return false;
  307 + }
  308 +
  309 + return false;
  310 + }
  311 +
  312 + private static string FormatTemplateBadge(decimal w, decimal h, string? unit)
  313 + {
  314 + var u = (unit ?? "inch").Trim().ToLowerInvariant();
  315 + var ws = w.ToString(CultureInfo.InvariantCulture);
  316 + var hs = h.ToString(CultureInfo.InvariantCulture);
  317 + return u is "inch" or "in"
  318 + ? $"{ws}\"x{hs}\""
  319 + : $"{ws}x{hs}{u}";
  320 + }
  321 +
165 322 private static DashboardMetricCardDto BuildMetricCard(string key, string title, int value, int previousValue)
166 323 {
167 324 var changeValue = value - previousValue;
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlGroupDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +/// <summary>
  6 +/// 组织/分组(Account Management / Group,表 fl_group)
  7 +/// </summary>
  8 +[SugarTable("fl_group")]
  9 +public class FlGroupDbEntity
  10 +{
  11 + [SugarColumn(IsPrimaryKey = true)]
  12 + public string Id { get; set; } = string.Empty;
  13 +
  14 + public bool IsDeleted { get; set; }
  15 +
  16 + public DateTime CreationTime { get; set; }
  17 +
  18 + public string? CreatorId { get; set; }
  19 +
  20 + public string? LastModifierId { get; set; }
  21 +
  22 + public DateTime? LastModificationTime { get; set; }
  23 +
  24 + /// <summary>
  25 + /// 组织名称(Group Name)
  26 + /// </summary>
  27 + public string GroupName { get; set; } = string.Empty;
  28 +
  29 + /// <summary>
  30 + /// 所属合作伙伴 Id(fl_partner.Id)
  31 + /// </summary>
  32 + public string PartnerId { get; set; } = string.Empty;
  33 +
  34 + /// <summary>
  35 + /// 是否启用(对应 UI Active)
  36 + /// </summary>
  37 + public bool State { get; set; }
  38 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlPartnerDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +/// <summary>
  6 +/// 合作伙伴主数据(Account Management / Partner,表 fl_partner)
  7 +/// </summary>
  8 +[SugarTable("fl_partner")]
  9 +public class FlPartnerDbEntity
  10 +{
  11 + [SugarColumn(IsPrimaryKey = true)]
  12 + public string Id { get; set; } = string.Empty;
  13 +
  14 + public bool IsDeleted { get; set; }
  15 +
  16 + public DateTime CreationTime { get; set; }
  17 +
  18 + public string? CreatorId { get; set; }
  19 +
  20 + public string? LastModifierId { get; set; }
  21 +
  22 + public DateTime? LastModificationTime { get; set; }
  23 +
  24 + /// <summary>
  25 + /// 合作伙伴名称(公司名)
  26 + /// </summary>
  27 + public string PartnerName { get; set; } = string.Empty;
  28 +
  29 + /// <summary>
  30 + /// 联系邮箱
  31 + /// </summary>
  32 + public string? ContactEmail { get; set; }
  33 +
  34 + /// <summary>
  35 + /// 电话
  36 + /// </summary>
  37 + public string? PhoneNumber { get; set; }
  38 +
  39 + /// <summary>
  40 + /// 是否启用(对应 UI Active)
  41 + /// </summary>
  42 + public bool State { get; set; }
  43 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Helpers;
  2 +using FoodLabeling.Application.Contracts.Dtos.Common;
  3 +using FoodLabeling.Application.Contracts.Dtos.Group;
  4 +using FoodLabeling.Application.Contracts.IServices;
  5 +using FoodLabeling.Application.Services.DbModels;
  6 +using Microsoft.AspNetCore.Mvc;
  7 +using QuestPDF.Fluent;
  8 +using QuestPDF.Helpers;
  9 +using QuestPDF.Infrastructure;
  10 +using SqlSugar;
  11 +using Volo.Abp;
  12 +using Volo.Abp.Application.Services;
  13 +using Volo.Abp.Guids;
  14 +using Volo.Abp.Uow;
  15 +using Yi.Framework.SqlSugarCore.Abstractions;
  16 +
  17 +namespace FoodLabeling.Application.Services;
  18 +
  19 +/// <summary>
  20 +/// 组织(Group)管理(fl_group)
  21 +/// </summary>
  22 +public class GroupAppService : ApplicationService, IGroupAppService
  23 +{
  24 + private const int ExportPdfMaxRows = 5000;
  25 +
  26 + private readonly ISqlSugarDbContext _dbContext;
  27 + private readonly IGuidGenerator _guidGenerator;
  28 +
  29 + public GroupAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator)
  30 + {
  31 + _dbContext = dbContext;
  32 + _guidGenerator = guidGenerator;
  33 + }
  34 +
  35 + /// <inheritdoc />
  36 + public async Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input)
  37 + {
  38 + RefAsync<int> total = 0;
  39 + var query = BuildGroupJoinedQuery(input);
  40 + var projected = query.Select((g, p) => new GroupGetListOutputDto
  41 + {
  42 + Id = g.Id,
  43 + GroupName = g.GroupName,
  44 + PartnerId = g.PartnerId,
  45 + PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "无" : p.PartnerName.Trim(),
  46 + State = g.State,
  47 + CreationTime = g.CreationTime
  48 + });
  49 +
  50 + var items = await projected.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
  51 + return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
  52 + }
  53 +
  54 + /// <inheritdoc />
  55 + public async Task<GroupGetOutputDto> GetAsync(string id)
  56 + {
  57 + var groupId = id?.Trim();
  58 + if (string.IsNullOrWhiteSpace(groupId))
  59 + {
  60 + throw new UserFriendlyException("组织Id不能为空");
  61 + }
  62 +
  63 + var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
  64 + .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
  65 + if (entity is null)
  66 + {
  67 + throw new UserFriendlyException("组织不存在");
  68 + }
  69 +
  70 + var partnerName = await ResolvePartnerNameAsync(entity.PartnerId);
  71 + return MapDetail(entity, partnerName);
  72 + }
  73 +
  74 + /// <inheritdoc />
  75 + [UnitOfWork]
  76 + public async Task<GroupGetOutputDto> CreateAsync(GroupCreateInputVo input)
  77 + {
  78 + var name = input.GroupName?.Trim();
  79 + if (string.IsNullOrWhiteSpace(name))
  80 + {
  81 + throw new UserFriendlyException("组织名称不能为空");
  82 + }
  83 +
  84 + var partnerId = input.PartnerId?.Trim();
  85 + if (string.IsNullOrWhiteSpace(partnerId))
  86 + {
  87 + throw new UserFriendlyException("请选择所属合作伙伴");
  88 + }
  89 +
  90 + await EnsurePartnerExistsAsync(partnerId);
  91 +
  92 + var now = Clock.Now;
  93 + var entity = new FlGroupDbEntity
  94 + {
  95 + Id = _guidGenerator.Create().ToString(),
  96 + IsDeleted = false,
  97 + GroupName = name,
  98 + PartnerId = partnerId,
  99 + State = input.State,
  100 + CreationTime = now,
  101 + CreatorId = CurrentUser?.Id?.ToString(),
  102 + LastModificationTime = now,
  103 + LastModifierId = CurrentUser?.Id?.ToString()
  104 + };
  105 +
  106 + await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
  107 + return await GetAsync(entity.Id);
  108 + }
  109 +
  110 + /// <inheritdoc />
  111 + [UnitOfWork]
  112 + public async Task<GroupGetOutputDto> UpdateAsync(string id, GroupUpdateInputVo input)
  113 + {
  114 + var groupId = id?.Trim();
  115 + if (string.IsNullOrWhiteSpace(groupId))
  116 + {
  117 + throw new UserFriendlyException("组织Id不能为空");
  118 + }
  119 +
  120 + var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
  121 + .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
  122 + if (entity is null)
  123 + {
  124 + throw new UserFriendlyException("组织不存在");
  125 + }
  126 +
  127 + var name = input.GroupName?.Trim();
  128 + if (string.IsNullOrWhiteSpace(name))
  129 + {
  130 + throw new UserFriendlyException("组织名称不能为空");
  131 + }
  132 +
  133 + var partnerId = input.PartnerId?.Trim();
  134 + if (string.IsNullOrWhiteSpace(partnerId))
  135 + {
  136 + throw new UserFriendlyException("请选择所属合作伙伴");
  137 + }
  138 +
  139 + await EnsurePartnerExistsAsync(partnerId);
  140 +
  141 + entity.GroupName = name;
  142 + entity.PartnerId = partnerId;
  143 + entity.State = input.State;
  144 + entity.LastModificationTime = Clock.Now;
  145 + entity.LastModifierId = CurrentUser?.Id?.ToString();
  146 +
  147 + await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
  148 + return await GetAsync(groupId);
  149 + }
  150 +
  151 + /// <inheritdoc />
  152 + [UnitOfWork]
  153 + public async Task DeleteAsync(string id)
  154 + {
  155 + var groupId = id?.Trim();
  156 + if (string.IsNullOrWhiteSpace(groupId))
  157 + {
  158 + throw new UserFriendlyException("组织Id不能为空");
  159 + }
  160 +
  161 + var entity = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
  162 + .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
  163 + if (entity is null)
  164 + {
  165 + throw new UserFriendlyException("组织不存在");
  166 + }
  167 +
  168 + entity.IsDeleted = true;
  169 + entity.LastModificationTime = Clock.Now;
  170 + entity.LastModifierId = CurrentUser?.Id?.ToString();
  171 + await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
  172 + }
  173 +
  174 + /// <inheritdoc />
  175 + public async Task<IActionResult> ExportPdfAsync(GroupGetListInputVo input)
  176 + {
  177 + QuestPDF.Settings.License = LicenseType.Community;
  178 +
  179 + var exportBase = BuildGroupJoinedQuery(input);
  180 + var count = await exportBase.CountAsync();
  181 + if (count > ExportPdfMaxRows)
  182 + {
  183 + throw new UserFriendlyException($"导出数据超过上限 {ExportPdfMaxRows} 条,请缩小筛选范围");
  184 + }
  185 +
  186 + var rows = await BuildGroupJoinedQuery(input)
  187 + .Select((g, p) => new GroupGetListOutputDto
  188 + {
  189 + Id = g.Id,
  190 + GroupName = g.GroupName,
  191 + PartnerId = g.PartnerId,
  192 + PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "无" : p.PartnerName.Trim(),
  193 + State = g.State,
  194 + CreationTime = g.CreationTime
  195 + })
  196 + .Take(ExportPdfMaxRows)
  197 + .ToListAsync();
  198 +
  199 + var fileName = $"groups_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
  200 + var document = Document.Create(container =>
  201 + {
  202 + container.Page(page =>
  203 + {
  204 + page.Margin(28);
  205 + page.DefaultTextStyle(x => x.FontSize(10));
  206 + page.Header().Text("Groups").SemiBold().FontSize(18);
  207 + page.Content().PaddingTop(12).Table(table =>
  208 + {
  209 + table.ColumnsDefinition(c =>
  210 + {
  211 + c.RelativeColumn(2.2f);
  212 + c.RelativeColumn(2.4f);
  213 + c.RelativeColumn(1f);
  214 + c.RelativeColumn(1.6f);
  215 + });
  216 +
  217 + static IContainer CellHeader(IContainer c) =>
  218 + c.Background(Colors.Grey.Lighten3).Padding(6).DefaultTextStyle(x => x.SemiBold());
  219 +
  220 + table.Cell().Element(CellHeader).Text("Group Name");
  221 + table.Cell().Element(CellHeader).Text("Parent Partner");
  222 + table.Cell().Element(CellHeader).Text("Status");
  223 + table.Cell().Element(CellHeader).Text("Created");
  224 +
  225 + foreach (var e in rows)
  226 + {
  227 + var status = e.State ? "active" : "inactive";
  228 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
  229 + .Text(e.GroupName ?? string.Empty);
  230 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
  231 + .Text(string.IsNullOrWhiteSpace(e.PartnerName) ? "无" : e.PartnerName);
  232 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status);
  233 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
  234 + .Text(e.CreationTime.ToString("yyyy-MM-dd HH:mm"));
  235 + }
  236 + });
  237 + });
  238 + });
  239 +
  240 + var stream = new MemoryStream();
  241 + document.GeneratePdf(stream);
  242 + stream.Position = 0;
  243 + return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
  244 + }
  245 +
  246 + private ISugarQueryable<FlGroupDbEntity, FlPartnerDbEntity> BuildGroupJoinedQuery(GroupGetListInputVo input)
  247 + {
  248 + var keyword = input.Keyword?.Trim();
  249 + var partnerId = input.PartnerId?.Trim();
  250 +
  251 + var query = _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
  252 + .LeftJoin<FlPartnerDbEntity>((g, p) => g.PartnerId == p.Id && !p.IsDeleted)
  253 + .Where((g, p) => !g.IsDeleted)
  254 + .WhereIF(input.State != null, (g, p) => g.State == input.State)
  255 + .WhereIF(!string.IsNullOrWhiteSpace(partnerId), (g, p) => g.PartnerId == partnerId)
  256 + .WhereIF(!string.IsNullOrWhiteSpace(keyword),
  257 + (g, p) => g.GroupName.Contains(keyword!) ||
  258 + (p.PartnerName != null && p.PartnerName.Contains(keyword!)));
  259 +
  260 + if (!string.IsNullOrWhiteSpace(input.Sorting))
  261 + {
  262 + var sorting = input.Sorting.Trim();
  263 + if (sorting.Equals("GroupName desc", StringComparison.OrdinalIgnoreCase))
  264 + {
  265 + query = query.OrderByDescending((g, p) => g.GroupName);
  266 + }
  267 + else if (sorting.Equals("GroupName asc", StringComparison.OrdinalIgnoreCase))
  268 + {
  269 + query = query.OrderBy((g, p) => g.GroupName);
  270 + }
  271 + else if (sorting.Equals("CreationTime desc", StringComparison.OrdinalIgnoreCase))
  272 + {
  273 + query = query.OrderByDescending((g, p) => g.CreationTime);
  274 + }
  275 + else if (sorting.Equals("CreationTime asc", StringComparison.OrdinalIgnoreCase))
  276 + {
  277 + query = query.OrderBy((g, p) => g.CreationTime);
  278 + }
  279 + else if (sorting.Equals("State desc", StringComparison.OrdinalIgnoreCase))
  280 + {
  281 + query = query.OrderByDescending((g, p) => g.State);
  282 + }
  283 + else if (sorting.Equals("State asc", StringComparison.OrdinalIgnoreCase))
  284 + {
  285 + query = query.OrderBy((g, p) => g.State);
  286 + }
  287 + else if (sorting.Equals("PartnerName desc", StringComparison.OrdinalIgnoreCase))
  288 + {
  289 + query = query.OrderByDescending((g, p) => p.PartnerName);
  290 + }
  291 + else if (sorting.Equals("PartnerName asc", StringComparison.OrdinalIgnoreCase))
  292 + {
  293 + query = query.OrderBy((g, p) => p.PartnerName);
  294 + }
  295 + else
  296 + {
  297 + query = query.OrderByDescending((g, p) => g.CreationTime);
  298 + }
  299 + }
  300 + else
  301 + {
  302 + query = query.OrderByDescending((g, p) => g.CreationTime);
  303 + }
  304 +
  305 + return query;
  306 + }
  307 +
  308 + private async Task EnsurePartnerExistsAsync(string partnerId)
  309 + {
  310 + var ok = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
  311 + .AnyAsync(x => !x.IsDeleted && x.Id == partnerId);
  312 + if (!ok)
  313 + {
  314 + throw new UserFriendlyException("所选合作伙伴不存在或已删除");
  315 + }
  316 + }
  317 +
  318 + private async Task<string> ResolvePartnerNameAsync(string partnerId)
  319 + {
  320 + var p = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
  321 + .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
  322 + if (p is null || string.IsNullOrWhiteSpace(p.PartnerName))
  323 + {
  324 + return "无";
  325 + }
  326 +
  327 + return p.PartnerName.Trim();
  328 + }
  329 +
  330 + private static GroupGetOutputDto MapDetail(FlGroupDbEntity x, string partnerName) => new()
  331 + {
  332 + Id = x.Id,
  333 + GroupName = x.GroupName,
  334 + PartnerId = x.PartnerId,
  335 + PartnerName = partnerName,
  336 + State = x.State,
  337 + CreationTime = x.CreationTime,
  338 + LastModificationTime = x.LastModificationTime
  339 + };
  340 +
  341 + private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total,
  342 + List<T> items)
  343 + {
  344 + var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
  345 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
  346 + var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
  347 + return new PagedResultWithPageDto<T>
  348 + {
  349 + PageIndex = pageIndex,
  350 + PageSize = pageSize,
  351 + TotalCount = total,
  352 + TotalPages = totalPages,
  353 + Items = items
  354 + };
  355 + }
  356 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Helpers;
  2 +using FoodLabeling.Application.Contracts.Dtos.Common;
  3 +using FoodLabeling.Application.Contracts.Dtos.Partner;
  4 +using FoodLabeling.Application.Contracts.IServices;
  5 +using FoodLabeling.Application.Services.DbModels;
  6 +using Microsoft.AspNetCore.Mvc;
  7 +using QuestPDF.Fluent;
  8 +using QuestPDF.Helpers;
  9 +using QuestPDF.Infrastructure;
  10 +using SqlSugar;
  11 +using Volo.Abp;
  12 +using Volo.Abp.Application.Services;
  13 +using Volo.Abp.Guids;
  14 +using Volo.Abp.Uow;
  15 +using Yi.Framework.SqlSugarCore.Abstractions;
  16 +
  17 +namespace FoodLabeling.Application.Services;
  18 +
  19 +/// <summary>
  20 +/// 合作伙伴管理(fl_partner)
  21 +/// </summary>
  22 +public class PartnerAppService : ApplicationService, IPartnerAppService
  23 +{
  24 + private const int ExportPdfMaxRows = 5000;
  25 +
  26 + private readonly ISqlSugarDbContext _dbContext;
  27 + private readonly IGuidGenerator _guidGenerator;
  28 +
  29 + public PartnerAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator)
  30 + {
  31 + _dbContext = dbContext;
  32 + _guidGenerator = guidGenerator;
  33 + }
  34 +
  35 + /// <inheritdoc />
  36 + public async Task<PagedResultWithPageDto<PartnerGetListOutputDto>> GetListAsync(PartnerGetListInputVo input)
  37 + {
  38 + RefAsync<int> total = 0;
  39 + var query = BuildPartnerListQuery(input);
  40 +
  41 + var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
  42 + var items = entities.Select(MapListItem).ToList();
  43 + return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
  44 + }
  45 +
  46 + /// <inheritdoc />
  47 + public async Task<PartnerGetOutputDto> GetAsync(string id)
  48 + {
  49 + var partnerId = id?.Trim();
  50 + if (string.IsNullOrWhiteSpace(partnerId))
  51 + {
  52 + throw new UserFriendlyException("合作伙伴Id不能为空");
  53 + }
  54 +
  55 + var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
  56 + .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
  57 + if (entity is null)
  58 + {
  59 + throw new UserFriendlyException("合作伙伴不存在");
  60 + }
  61 +
  62 + return MapDetail(entity);
  63 + }
  64 +
  65 + /// <inheritdoc />
  66 + [UnitOfWork]
  67 + public async Task<PartnerGetOutputDto> CreateAsync(PartnerCreateInputVo input)
  68 + {
  69 + var name = input.PartnerName?.Trim();
  70 + if (string.IsNullOrWhiteSpace(name))
  71 + {
  72 + throw new UserFriendlyException("合作伙伴名称不能为空");
  73 + }
  74 +
  75 + var email = input.ContactEmail?.Trim();
  76 + if (!string.IsNullOrWhiteSpace(email) && !IsPlausibleEmail(email))
  77 + {
  78 + throw new UserFriendlyException("联系邮箱格式不正确");
  79 + }
  80 +
  81 + var now = Clock.Now;
  82 + var entity = new FlPartnerDbEntity
  83 + {
  84 + Id = _guidGenerator.Create().ToString(),
  85 + IsDeleted = false,
  86 + PartnerName = name,
  87 + ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email,
  88 + PhoneNumber = string.IsNullOrWhiteSpace(input.PhoneNumber) ? null : input.PhoneNumber.Trim(),
  89 + State = input.State,
  90 + CreationTime = now,
  91 + CreatorId = CurrentUser?.Id?.ToString(),
  92 + LastModificationTime = now,
  93 + LastModifierId = CurrentUser?.Id?.ToString()
  94 + };
  95 +
  96 + await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
  97 + return await GetAsync(entity.Id);
  98 + }
  99 +
  100 + /// <inheritdoc />
  101 + [UnitOfWork]
  102 + public async Task<PartnerGetOutputDto> UpdateAsync(string id, PartnerUpdateInputVo input)
  103 + {
  104 + var partnerId = id?.Trim();
  105 + if (string.IsNullOrWhiteSpace(partnerId))
  106 + {
  107 + throw new UserFriendlyException("合作伙伴Id不能为空");
  108 + }
  109 +
  110 + var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
  111 + .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
  112 + if (entity is null)
  113 + {
  114 + throw new UserFriendlyException("合作伙伴不存在");
  115 + }
  116 +
  117 + var name = input.PartnerName?.Trim();
  118 + if (string.IsNullOrWhiteSpace(name))
  119 + {
  120 + throw new UserFriendlyException("合作伙伴名称不能为空");
  121 + }
  122 +
  123 + var email = input.ContactEmail?.Trim();
  124 + if (!string.IsNullOrWhiteSpace(email) && !IsPlausibleEmail(email))
  125 + {
  126 + throw new UserFriendlyException("联系邮箱格式不正确");
  127 + }
  128 +
  129 + entity.PartnerName = name;
  130 + entity.ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email;
  131 + entity.PhoneNumber = string.IsNullOrWhiteSpace(input.PhoneNumber) ? null : input.PhoneNumber.Trim();
  132 + entity.State = input.State;
  133 + entity.LastModificationTime = Clock.Now;
  134 + entity.LastModifierId = CurrentUser?.Id?.ToString();
  135 +
  136 + await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
  137 + return await GetAsync(partnerId);
  138 + }
  139 +
  140 + /// <inheritdoc />
  141 + [UnitOfWork]
  142 + public async Task DeleteAsync(string id)
  143 + {
  144 + var partnerId = id?.Trim();
  145 + if (string.IsNullOrWhiteSpace(partnerId))
  146 + {
  147 + throw new UserFriendlyException("合作伙伴Id不能为空");
  148 + }
  149 +
  150 + var entity = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
  151 + .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
  152 + if (entity is null)
  153 + {
  154 + throw new UserFriendlyException("合作伙伴不存在");
  155 + }
  156 +
  157 + entity.IsDeleted = true;
  158 + entity.LastModificationTime = Clock.Now;
  159 + entity.LastModifierId = CurrentUser?.Id?.ToString();
  160 + await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
  161 + }
  162 +
  163 + /// <inheritdoc />
  164 + public async Task<IActionResult> ExportPdfAsync(PartnerGetListInputVo input)
  165 + {
  166 + QuestPDF.Settings.License = LicenseType.Community;
  167 +
  168 + var count = await BuildPartnerListQuery(input).CountAsync();
  169 + var query = BuildPartnerListQuery(input);
  170 + if (count > ExportPdfMaxRows)
  171 + {
  172 + throw new UserFriendlyException($"导出数据超过上限 {ExportPdfMaxRows} 条,请缩小筛选范围");
  173 + }
  174 +
  175 + var rows = await query.Take(ExportPdfMaxRows).ToListAsync();
  176 + var fileName = $"partners_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
  177 +
  178 + var document = Document.Create(container =>
  179 + {
  180 + container.Page(page =>
  181 + {
  182 + page.Margin(28);
  183 + page.DefaultTextStyle(x => x.FontSize(10));
  184 + page.Header().Text("Partners").SemiBold().FontSize(18);
  185 + page.Content().PaddingTop(12).Table(table =>
  186 + {
  187 + table.ColumnsDefinition(c =>
  188 + {
  189 + c.RelativeColumn(2.2f);
  190 + c.RelativeColumn(2.4f);
  191 + c.RelativeColumn(1.8f);
  192 + c.RelativeColumn(1f);
  193 + c.RelativeColumn(1.6f);
  194 + });
  195 +
  196 + static IContainer CellHeader(IContainer c) =>
  197 + c.Background(Colors.Grey.Lighten3).Padding(6).DefaultTextStyle(x => x.SemiBold());
  198 +
  199 + table.Cell().Element(CellHeader).Text("Partner");
  200 + table.Cell().Element(CellHeader).Text("Contact");
  201 + table.Cell().Element(CellHeader).Text("Phone");
  202 + table.Cell().Element(CellHeader).Text("Status");
  203 + table.Cell().Element(CellHeader).Text("Created");
  204 +
  205 + foreach (var e in rows)
  206 + {
  207 + var status = e.State ? "active" : "inactive";
  208 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
  209 + .Text(e.PartnerName ?? string.Empty);
  210 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
  211 + .Text(string.IsNullOrWhiteSpace(e.ContactEmail) ? "无" : e.ContactEmail!);
  212 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
  213 + .Text(string.IsNullOrWhiteSpace(e.PhoneNumber) ? "无" : e.PhoneNumber!);
  214 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status);
  215 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
  216 + .Text(e.CreationTime.ToString("yyyy-MM-dd HH:mm"));
  217 + }
  218 + });
  219 + });
  220 + });
  221 +
  222 + var stream = new MemoryStream();
  223 + document.GeneratePdf(stream);
  224 + stream.Position = 0;
  225 + return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
  226 + }
  227 +
  228 + private ISugarQueryable<FlPartnerDbEntity> BuildPartnerListQuery(PartnerGetListInputVo input)
  229 + {
  230 + var keyword = input.Keyword?.Trim();
  231 + var query = _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
  232 + .Where(x => !x.IsDeleted)
  233 + .WhereIF(input.State != null, x => x.State == input.State)
  234 + .WhereIF(!string.IsNullOrWhiteSpace(keyword),
  235 + x => x.PartnerName.Contains(keyword!) ||
  236 + (x.ContactEmail != null && x.ContactEmail.Contains(keyword!)) ||
  237 + (x.PhoneNumber != null && x.PhoneNumber.Contains(keyword!)));
  238 +
  239 + if (!string.IsNullOrWhiteSpace(input.Sorting))
  240 + {
  241 + var sorting = input.Sorting.Trim();
  242 + if (sorting.Equals("PartnerName desc", StringComparison.OrdinalIgnoreCase))
  243 + {
  244 + query = query.OrderByDescending(x => x.PartnerName);
  245 + }
  246 + else if (sorting.Equals("PartnerName asc", StringComparison.OrdinalIgnoreCase))
  247 + {
  248 + query = query.OrderBy(x => x.PartnerName);
  249 + }
  250 + else if (sorting.Equals("CreationTime desc", StringComparison.OrdinalIgnoreCase))
  251 + {
  252 + query = query.OrderByDescending(x => x.CreationTime);
  253 + }
  254 + else if (sorting.Equals("CreationTime asc", StringComparison.OrdinalIgnoreCase))
  255 + {
  256 + query = query.OrderBy(x => x.CreationTime);
  257 + }
  258 + else if (sorting.Equals("State desc", StringComparison.OrdinalIgnoreCase))
  259 + {
  260 + query = query.OrderByDescending(x => x.State);
  261 + }
  262 + else if (sorting.Equals("State asc", StringComparison.OrdinalIgnoreCase))
  263 + {
  264 + query = query.OrderBy(x => x.State);
  265 + }
  266 + else
  267 + {
  268 + query = query.OrderByDescending(x => x.CreationTime);
  269 + }
  270 + }
  271 + else
  272 + {
  273 + query = query.OrderByDescending(x => x.CreationTime);
  274 + }
  275 +
  276 + return query;
  277 + }
  278 +
  279 + private static PartnerGetListOutputDto MapListItem(FlPartnerDbEntity x) => new()
  280 + {
  281 + Id = x.Id,
  282 + PartnerName = x.PartnerName,
  283 + ContactEmail = x.ContactEmail,
  284 + PhoneNumber = x.PhoneNumber,
  285 + State = x.State,
  286 + CreationTime = x.CreationTime
  287 + };
  288 +
  289 + private static PartnerGetOutputDto MapDetail(FlPartnerDbEntity x) => new()
  290 + {
  291 + Id = x.Id,
  292 + PartnerName = x.PartnerName,
  293 + ContactEmail = x.ContactEmail,
  294 + PhoneNumber = x.PhoneNumber,
  295 + State = x.State,
  296 + CreationTime = x.CreationTime,
  297 + LastModificationTime = x.LastModificationTime
  298 + };
  299 +
  300 + private static bool IsPlausibleEmail(string email)
  301 + {
  302 + if (email.Length > 256)
  303 + {
  304 + return false;
  305 + }
  306 +
  307 + var at = email.IndexOf('@');
  308 + return at > 0 && at < email.Length - 1 && email.IndexOf('@', at + 1) < 0;
  309 + }
  310 +
  311 + private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total,
  312 + List<T> items)
  313 + {
  314 + var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
  315 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
  316 + var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
  317 + return new PagedResultWithPageDto<T>
  318 + {
  319 + PageIndex = pageIndex,
  320 + PageSize = pageSize,
  321 + TotalCount = total,
  322 + TotalPages = totalPages,
  323 + Items = items
  324 + };
  325 + }
  326 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ReportsAppService.cs 0 → 100644
  1 +using System.Globalization;
  2 +using System.Text.Json;
  3 +using FoodLabeling.Application.Helpers;
  4 +using FoodLabeling.Application.Contracts.Dtos.Common;
  5 +using FoodLabeling.Application.Contracts.Dtos.Reports;
  6 +using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
  7 +using FoodLabeling.Application.Contracts.IServices;
  8 +using FoodLabeling.Application.Services.DbModels;
  9 +using FoodLabeling.Domain.Entities;
  10 +using Microsoft.AspNetCore.Authorization;
  11 +using Microsoft.AspNetCore.Mvc;
  12 +using QuestPDF.Fluent;
  13 +using QuestPDF.Helpers;
  14 +using QuestPDF.Infrastructure;
  15 +using SqlSugar;
  16 +using Volo.Abp;
  17 +using Volo.Abp.Application.Services;
  18 +using Yi.Framework.Rbac.Domain.Entities;
  19 +using Yi.Framework.SqlSugarCore.Abstractions;
  20 +
  21 +namespace FoodLabeling.Application.Services;
  22 +
  23 +/// <summary>
  24 +/// Reports(Print Log / Label Report)
  25 +/// </summary>
  26 +[Authorize]
  27 +public class ReportsAppService : ApplicationService, IReportsAppService
  28 +{
  29 + private const int ExportPdfMaxRows = 5000;
  30 +
  31 + private readonly ISqlSugarDbContext _dbContext;
  32 + private readonly IUsAppLabelingAppService _usAppLabelingAppService;
  33 +
  34 + public ReportsAppService(ISqlSugarDbContext dbContext, IUsAppLabelingAppService usAppLabelingAppService)
  35 + {
  36 + _dbContext = dbContext;
  37 + _usAppLabelingAppService = usAppLabelingAppService;
  38 + }
  39 +
  40 + /// <inheritdoc />
  41 + public async Task<PagedResultWithPageDto<ReportsPrintLogListItemDto>> GetPrintLogListAsync(
  42 + ReportsPrintLogGetListInputVo input)
  43 + {
  44 + if (input is null)
  45 + {
  46 + throw new UserFriendlyException("入参不能为空");
  47 + }
  48 +
  49 + if (!CurrentUser.Id.HasValue)
  50 + {
  51 + throw new UserFriendlyException("用户未登录");
  52 + }
  53 +
  54 + var locationIds = await ResolveFilteredLocationIdsAsync(input.PartnerId, input.GroupId, input.LocationId);
  55 + if (locationIds is not null && locationIds.Count == 0)
  56 + {
  57 + return EmptyPrintLogPage(input);
  58 + }
  59 +
  60 + var (rangeStart, rangeEndExcl) = ResolveDateRange(input.StartDate, input.EndDate);
  61 + var isAdmin = ReportsRoleHelper.IsAdminRole(CurrentUser);
  62 + var currentUserIdStr = CurrentUser.Id.Value.ToString();
  63 + var keyword = input.Keyword?.Trim();
  64 +
  65 + RefAsync<int> total = 0;
  66 +
  67 + var query = BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
  68 + .LeftJoin<FlLabelTemplateDbEntity>((t, l, p, lc, pc, loc, tpl) => t.TemplateId == tpl.Id)
  69 + .Where((t, l, p, lc, pc, loc, tpl) =>
  70 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= rangeStart &&
  71 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < rangeEndExcl);
  72 +
  73 + if (!string.IsNullOrWhiteSpace(input.Sorting) &&
  74 + input.Sorting.Trim().Equals("PrintedAt asc", StringComparison.OrdinalIgnoreCase))
  75 + {
  76 + query = query.OrderBy((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
  77 + OrderByType.Asc);
  78 + }
  79 + else
  80 + {
  81 + query = query.OrderBy((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
  82 + OrderByType.Desc);
  83 + }
  84 +
  85 + var pageRows = await query
  86 + .Select((t, l, p, lc, pc, loc, tpl) => new
  87 + {
  88 + t.Id,
  89 + LabelCode = l.LabelCode,
  90 + ProductName = p.ProductName,
  91 + LabelCategoryName = lc.CategoryName,
  92 + ProductCategoryName = pc.CategoryName,
  93 + tpl.Width,
  94 + tpl.Height,
  95 + tpl.Unit,
  96 + tpl.TemplateName,
  97 + t.PrintInputJson,
  98 + PrintedAt = SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
  99 + t.CreatedBy,
  100 + t.LocationId,
  101 + LocName = loc.LocationName,
  102 + LocCode = loc.LocationCode
  103 + })
  104 + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
  105 +
  106 + var userMap = await LoadUserNameMapAsync(pageRows.Select(x => x.CreatedBy).Where(x => !string.IsNullOrWhiteSpace(x))
  107 + .Select(x => x!).Distinct().ToList());
  108 +
  109 + var items = pageRows.Select(x =>
  110 + {
  111 + var cat = !string.IsNullOrWhiteSpace(x.ProductCategoryName)
  112 + ? x.ProductCategoryName!.Trim()
  113 + : (string.IsNullOrWhiteSpace(x.LabelCategoryName) ? "无" : x.LabelCategoryName.Trim());
  114 + var templateText = FormatTemplateDisplay(x.Width, x.Height, x.Unit, x.TemplateName);
  115 + var locText = FormatLocationText(x.LocName, x.LocCode);
  116 + var printedAt = x.PrintedAt ?? DateTime.MinValue;
  117 + return new ReportsPrintLogListItemDto
  118 + {
  119 + TaskId = x.Id,
  120 + LabelCode = string.IsNullOrWhiteSpace(x.LabelCode) ? "无" : x.LabelCode.Trim(),
  121 + ProductName = string.IsNullOrWhiteSpace(x.ProductName) ? "无" : x.ProductName.Trim(),
  122 + CategoryName = string.IsNullOrWhiteSpace(cat) ? "无" : cat,
  123 + TemplateText = string.IsNullOrWhiteSpace(templateText) ? "无" : templateText,
  124 + PrintedAt = printedAt,
  125 + PrintedByName = ResolveUserName(userMap, x.CreatedBy),
  126 + LocationText = locText,
  127 + LocationId = x.LocationId?.Trim(),
  128 + ExpiryDateText = TryExtractExpiryText(x.PrintInputJson)
  129 + };
  130 + }).ToList();
  131 +
  132 + return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
  133 + }
  134 +
  135 + /// <inheritdoc />
  136 + public async Task<IActionResult> ExportPrintLogPdfAsync(ReportsPrintLogGetListInputVo input)
  137 + {
  138 + QuestPDF.Settings.License = LicenseType.Community;
  139 + if (input is null)
  140 + {
  141 + throw new UserFriendlyException("入参不能为空");
  142 + }
  143 +
  144 + if (!CurrentUser.Id.HasValue)
  145 + {
  146 + throw new UserFriendlyException("用户未登录");
  147 + }
  148 +
  149 + var locationIds = await ResolveFilteredLocationIdsAsync(input.PartnerId, input.GroupId, input.LocationId);
  150 + if (locationIds is not null && locationIds.Count == 0)
  151 + {
  152 + return BuildEmptyPdf("print-log-empty.pdf");
  153 + }
  154 +
  155 + var (rangeStart, rangeEndExcl) = ResolveDateRange(input.StartDate, input.EndDate);
  156 + var isAdmin = ReportsRoleHelper.IsAdminRole(CurrentUser);
  157 + var currentUserIdStr = CurrentUser.Id.Value.ToString();
  158 + var keyword = input.Keyword?.Trim();
  159 +
  160 + var query = BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
  161 + .LeftJoin<FlLabelTemplateDbEntity>((t, l, p, lc, pc, loc, tpl) => t.TemplateId == tpl.Id)
  162 + .Where((t, l, p, lc, pc, loc, tpl) =>
  163 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= rangeStart &&
  164 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < rangeEndExcl)
  165 + .OrderBy((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime), OrderByType.Desc);
  166 +
  167 + var count = await query.CountAsync();
  168 + if (count > ExportPdfMaxRows)
  169 + {
  170 + throw new UserFriendlyException($"导出数据超过上限 {ExportPdfMaxRows} 条,请缩小筛选范围");
  171 + }
  172 +
  173 + var rows = await query.Take(ExportPdfMaxRows)
  174 + .Select((t, l, p, lc, pc, loc, tpl) => new
  175 + {
  176 + t.Id,
  177 + LabelCode = l.LabelCode,
  178 + ProductName = p.ProductName,
  179 + LabelCategoryName = lc.CategoryName,
  180 + ProductCategoryName = pc.CategoryName,
  181 + tpl.Width,
  182 + tpl.Height,
  183 + tpl.Unit,
  184 + tpl.TemplateName,
  185 + t.PrintInputJson,
  186 + PrintedAt = SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
  187 + t.CreatedBy,
  188 + LocName = loc.LocationName,
  189 + LocCode = loc.LocationCode
  190 + })
  191 + .ToListAsync();
  192 +
  193 + var userMap = await LoadUserNameMapAsync(rows.Select(x => x.CreatedBy).Where(x => !string.IsNullOrWhiteSpace(x))
  194 + .Select(x => x!).Distinct().ToList());
  195 +
  196 + var fileName = $"print-log_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
  197 + var document = Document.Create(container =>
  198 + {
  199 + container.Page(page =>
  200 + {
  201 + page.Margin(22);
  202 + page.DefaultTextStyle(x => x.FontSize(8.5f));
  203 + page.Header().Text("Print Log").SemiBold().FontSize(16);
  204 + page.Content().PaddingTop(10).Table(table =>
  205 + {
  206 + table.ColumnsDefinition(c =>
  207 + {
  208 + c.RelativeColumn(1.1f);
  209 + c.RelativeColumn(1.2f);
  210 + c.RelativeColumn(0.9f);
  211 + c.RelativeColumn(1.1f);
  212 + c.RelativeColumn(1f);
  213 + c.RelativeColumn(0.9f);
  214 + c.RelativeColumn(0.9f);
  215 + c.RelativeColumn(0.8f);
  216 + });
  217 + static IContainer H(IContainer x) =>
  218 + x.Background(Colors.Grey.Lighten3).Padding(4).DefaultTextStyle(s => s.SemiBold());
  219 + table.Cell().Element(H).Text("Label ID");
  220 + table.Cell().Element(H).Text("Product");
  221 + table.Cell().Element(H).Text("Category");
  222 + table.Cell().Element(H).Text("Template");
  223 + table.Cell().Element(H).Text("Printed At");
  224 + table.Cell().Element(H).Text("Printed By");
  225 + table.Cell().Element(H).Text("Location");
  226 + table.Cell().Element(H).Text("Expiry");
  227 +
  228 + foreach (var x in rows)
  229 + {
  230 + var cat = !string.IsNullOrWhiteSpace(x.ProductCategoryName)
  231 + ? x.ProductCategoryName!.Trim()
  232 + : (string.IsNullOrWhiteSpace(x.LabelCategoryName) ? "无" : x.LabelCategoryName.Trim());
  233 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  234 + .Text(string.IsNullOrWhiteSpace(x.LabelCode) ? "无" : x.LabelCode.Trim());
  235 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  236 + .Text(string.IsNullOrWhiteSpace(x.ProductName) ? "无" : x.ProductName.Trim());
  237 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(cat);
  238 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  239 + .Text(FormatTemplateDisplay(x.Width, x.Height, x.Unit, x.TemplateName));
  240 + var printedAt = x.PrintedAt ?? DateTime.MinValue;
  241 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  242 + .Text(printedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture));
  243 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  244 + .Text(ResolveUserName(userMap, x.CreatedBy));
  245 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  246 + .Text(FormatLocationText(x.LocName, x.LocCode));
  247 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  248 + .Text(TryExtractExpiryText(x.PrintInputJson));
  249 + }
  250 + });
  251 + });
  252 + });
  253 +
  254 + var ms = new MemoryStream();
  255 + document.GeneratePdf(ms);
  256 + ms.Position = 0;
  257 + return new FileStreamResult(ms, "application/pdf") { FileDownloadName = fileName };
  258 + }
  259 +
  260 + /// <inheritdoc />
  261 + public Task<UsAppLabelPrintOutputDto> ReprintPrintLogAsync(UsAppLabelReprintInputVo input) =>
  262 + _usAppLabelingAppService.ReprintAsync(input);
  263 +
  264 + /// <inheritdoc />
  265 + public async Task<ReportsLabelReportOutputDto> GetLabelReportAsync(ReportsLabelReportQueryInputVo input)
  266 + {
  267 + if (input is null)
  268 + {
  269 + throw new UserFriendlyException("入参不能为空");
  270 + }
  271 +
  272 + if (!CurrentUser.Id.HasValue)
  273 + {
  274 + throw new UserFriendlyException("用户未登录");
  275 + }
  276 +
  277 + var locationIds = await ResolveFilteredLocationIdsAsync(input.PartnerId, input.GroupId, input.LocationId);
  278 + if (locationIds is not null && locationIds.Count == 0)
  279 + {
  280 + return new ReportsLabelReportOutputDto();
  281 + }
  282 +
  283 + var (curStart, curEndExcl) = ResolveDateRange(input.StartDate, input.EndDate);
  284 + var span = curEndExcl - curStart;
  285 + if (span.TotalDays < 1)
  286 + {
  287 + span = TimeSpan.FromDays(1);
  288 + }
  289 +
  290 + var prevEndExcl = curStart;
  291 + var prevStart = curStart - span;
  292 + var isAdmin = ReportsRoleHelper.IsAdminRole(CurrentUser);
  293 + var currentUserIdStr = CurrentUser.Id.Value.ToString();
  294 + var keyword = input.Keyword?.Trim();
  295 +
  296 + var totalCur = await BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
  297 + .Where((t, l, p, lc, pc, loc) =>
  298 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= curStart &&
  299 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < curEndExcl)
  300 + .CountAsync();
  301 +
  302 + var totalPrev = await BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
  303 + .Where((t, l, p, lc, pc, loc) =>
  304 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= prevStart &&
  305 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < prevEndExcl)
  306 + .CountAsync();
  307 +
  308 + var dayCount = Math.Max(1, (int)Math.Ceiling((curEndExcl - curStart).TotalDays));
  309 + var prevDayCount = Math.Max(1, (int)Math.Ceiling((prevEndExcl - prevStart).TotalDays));
  310 + var avgDaily = Math.Round((decimal)totalCur / dayCount, 2);
  311 + var avgDailyPrev = Math.Round((decimal)totalPrev / prevDayCount, 2);
  312 +
  313 + var categoryRows = await BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
  314 + .Where((t, l, p, lc, pc, loc) =>
  315 + l.LabelCategoryId != null &&
  316 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= curStart &&
  317 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < curEndExcl)
  318 + .GroupBy((t, l, p, lc, pc, loc) => new { lc.Id, lc.CategoryName })
  319 + .Select((t, l, p, lc, pc, loc) => new { lc.Id, lc.CategoryName, Cnt = SqlFunc.AggregateCount(t.Id) })
  320 + .ToListAsync();
  321 +
  322 + var topCat = categoryRows.OrderByDescending(x => x.Cnt).FirstOrDefault();
  323 +
  324 + var productRows = await BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
  325 + .Where((t, l, p, lc, pc, loc) =>
  326 + !string.IsNullOrEmpty(p.Id) &&
  327 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= curStart &&
  328 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < curEndExcl)
  329 + .GroupBy((t, l, p, lc, pc, loc) => new { p.Id, p.ProductName, Cat = pc.CategoryName })
  330 + .Select((t, l, p, lc, pc, loc) => new { p.Id, p.ProductName, CategoryName = pc.CategoryName, Cnt = SqlFunc.AggregateCount(t.Id) })
  331 + .ToListAsync();
  332 +
  333 + var topProd = productRows.OrderByDescending(x => x.Cnt).FirstOrDefault();
  334 + var topList = productRows.OrderByDescending(x => x.Cnt).Take(20).ToList();
  335 +
  336 + var trendEndDay = curEndExcl.Date.AddDays(-1);
  337 + var trendStartDay = trendEndDay.AddDays(-6);
  338 + if (trendStartDay < curStart.Date)
  339 + {
  340 + trendStartDay = curStart.Date;
  341 + }
  342 +
  343 + var trendEndExcl = trendEndDay.AddDays(1);
  344 +
  345 + var trendRaw = await BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
  346 + .Where((t, l, p, lc, pc, loc) =>
  347 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= trendStartDay &&
  348 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < trendEndExcl)
  349 + .Select((t, l, p, lc, pc, loc) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime))
  350 + .ToListAsync();
  351 +
  352 + var trendDict = trendRaw
  353 + .Where(x => x.HasValue)
  354 + .GroupBy(x => x!.Value.Date)
  355 + .ToDictionary(g => g.Key, g => g.Count());
  356 +
  357 + var trend = new List<ReportsDailyCountDto>();
  358 + for (var d = trendStartDay; d <= trendEndDay; d = d.AddDays(1))
  359 + {
  360 + trend.Add(new ReportsDailyCountDto
  361 + {
  362 + Date = d.ToString("yyyy-MM-dd"),
  363 + Count = trendDict.TryGetValue(d, out var c) ? c : 0
  364 + });
  365 + }
  366 +
  367 + var byCategory = categoryRows
  368 + .OrderByDescending(x => x.Cnt)
  369 + .Select(x => new ReportsCategoryCountDto
  370 + {
  371 + CategoryId = x.Id ?? string.Empty,
  372 + CategoryName = string.IsNullOrWhiteSpace(x.CategoryName) ? "无" : x.CategoryName.Trim(),
  373 + Count = x.Cnt
  374 + })
  375 + .ToList();
  376 +
  377 + var mostUsed = topList.Select(x =>
  378 + {
  379 + var pct = totalCur <= 0 ? 0m : Math.Round(x.Cnt * 100m / totalCur, 2);
  380 + return new ReportsTopProductRowDto
  381 + {
  382 + ProductId = x.Id ?? string.Empty,
  383 + ProductName = string.IsNullOrWhiteSpace(x.ProductName) ? "无" : x.ProductName.Trim(),
  384 + CategoryName = string.IsNullOrWhiteSpace(x.CategoryName) ? "无" : x.CategoryName!.Trim(),
  385 + TotalPrinted = x.Cnt,
  386 + UsagePercent = pct
  387 + };
  388 + }).ToList();
  389 +
  390 + return new ReportsLabelReportOutputDto
  391 + {
  392 + Summary = new ReportsLabelReportSummaryDto
  393 + {
  394 + TotalLabelsPrinted = totalCur,
  395 + TotalLabelsPrintedPrevPeriod = totalPrev,
  396 + TotalLabelsPrintedChangeRate = CalcChangeRate(totalCur, totalPrev),
  397 + MostPrintedCategoryName = topCat is null ? "无" : (topCat.CategoryName?.Trim() ?? "无"),
  398 + MostPrintedCategoryCount = topCat?.Cnt ?? 0,
  399 + TopProductName = topProd is null ? "无" : (topProd.ProductName?.Trim() ?? "无"),
  400 + TopProductCount = topProd?.Cnt ?? 0,
  401 + AvgDailyPrints = avgDaily,
  402 + AvgDailyPrintsPrevPeriod = avgDailyPrev,
  403 + AvgDailyPrintsChangeRate = CalcChangeRate(avgDaily, avgDailyPrev)
  404 + },
  405 + LabelsByCategory = byCategory,
  406 + PrintVolumeTrend = trend,
  407 + MostUsedProducts = mostUsed
  408 + };
  409 + }
  410 +
  411 + /// <inheritdoc />
  412 + public async Task<IActionResult> ExportLabelReportPdfAsync(ReportsLabelReportQueryInputVo input)
  413 + {
  414 + QuestPDF.Settings.License = LicenseType.Community;
  415 + var data = await GetLabelReportAsync(input);
  416 + var fileName = $"label-report_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
  417 + var document = Document.Create(container =>
  418 + {
  419 + container.Page(page =>
  420 + {
  421 + page.Margin(24);
  422 + page.DefaultTextStyle(x => x.FontSize(9));
  423 + page.Header().Text("Label Report").SemiBold().FontSize(16);
  424 + page.Content().Column(col =>
  425 + {
  426 + col.Spacing(10);
  427 + col.Item().Text(
  428 + $"Total printed: {data.Summary.TotalLabelsPrinted} (prev: {data.Summary.TotalLabelsPrintedPrevPeriod}, Δ%: {data.Summary.TotalLabelsPrintedChangeRate:0.##}%)");
  429 + col.Item().Text(
  430 + $"Top category: {data.Summary.MostPrintedCategoryName} ({data.Summary.MostPrintedCategoryCount})");
  431 + col.Item().Text($"Top product: {data.Summary.TopProductName} ({data.Summary.TopProductCount})");
  432 + col.Item().Text(
  433 + $"Avg daily: {data.Summary.AvgDailyPrints:0.##} (prev: {data.Summary.AvgDailyPrintsPrevPeriod:0.##}, Δ%: {data.Summary.AvgDailyPrintsChangeRate:0.##}%)");
  434 + col.Item().Text("By category:").SemiBold();
  435 + col.Item().Table(t =>
  436 + {
  437 + t.ColumnsDefinition(c => { c.RelativeColumn(2); c.RelativeColumn(1); });
  438 + t.Cell().Element(HeaderCell).Text("Category");
  439 + t.Cell().Element(HeaderCell).Text("Count");
  440 + foreach (var r in data.LabelsByCategory)
  441 + {
  442 + t.Cell().BorderBottom(0.5f).Padding(3).Text(r.CategoryName);
  443 + t.Cell().BorderBottom(0.5f).Padding(3).Text(r.Count.ToString());
  444 + }
  445 + });
  446 + col.Item().Text("Daily trend:").SemiBold();
  447 + col.Item().Table(t =>
  448 + {
  449 + t.ColumnsDefinition(c => { c.RelativeColumn(1.2f); c.RelativeColumn(1); });
  450 + t.Cell().Element(HeaderCell).Text("Date");
  451 + t.Cell().Element(HeaderCell).Text("Count");
  452 + foreach (var r in data.PrintVolumeTrend)
  453 + {
  454 + t.Cell().BorderBottom(0.5f).Padding(3).Text(r.Date);
  455 + t.Cell().BorderBottom(0.5f).Padding(3).Text(r.Count.ToString());
  456 + }
  457 + });
  458 + col.Item().Text("Most used products:").SemiBold();
  459 + col.Item().Table(t =>
  460 + {
  461 + t.ColumnsDefinition(c =>
  462 + {
  463 + c.RelativeColumn(1.5f);
  464 + c.RelativeColumn(1f);
  465 + c.RelativeColumn(0.8f);
  466 + c.RelativeColumn(0.7f);
  467 + });
  468 + t.Cell().Element(HeaderCell).Text("Product");
  469 + t.Cell().Element(HeaderCell).Text("Category");
  470 + t.Cell().Element(HeaderCell).Text("Total");
  471 + t.Cell().Element(HeaderCell).Text("%");
  472 + foreach (var r in data.MostUsedProducts)
  473 + {
  474 + t.Cell().BorderBottom(0.5f).Padding(3).Text(r.ProductName);
  475 + t.Cell().BorderBottom(0.5f).Padding(3).Text(r.CategoryName);
  476 + t.Cell().BorderBottom(0.5f).Padding(3).Text(r.TotalPrinted.ToString());
  477 + t.Cell().BorderBottom(0.5f).Padding(3).Text(r.UsagePercent.ToString("0.##"));
  478 + }
  479 + });
  480 + });
  481 + });
  482 + });
  483 +
  484 + static IContainer HeaderCell(IContainer x) =>
  485 + x.Background(Colors.Grey.Lighten3).Padding(4).DefaultTextStyle(s => s.SemiBold());
  486 +
  487 + var ms = new MemoryStream();
  488 + document.GeneratePdf(ms);
  489 + ms.Position = 0;
  490 + return new FileStreamResult(ms, "application/pdf") { FileDownloadName = fileName };
  491 + }
  492 +
  493 + private ISugarQueryable<FlLabelPrintTaskDbEntity, FlLabelDbEntity, FlProductDbEntity, FlLabelCategoryDbEntity,
  494 + FlProductCategoryDbEntity, LocationAggregateRoot>
  495 + BuildReportTaskCore(
  496 + List<string>? locationIds,
  497 + bool isAdmin,
  498 + string currentUserIdStr,
  499 + string? keyword)
  500 + {
  501 + return _dbContext.SqlSugarClient.Queryable<FlLabelPrintTaskDbEntity>()
  502 + .LeftJoin<FlLabelDbEntity>((t, l) => t.LabelId == l.Id)
  503 + .LeftJoin<FlProductDbEntity>((t, l, p) => t.ProductId == p.Id)
  504 + .LeftJoin<FlLabelCategoryDbEntity>((t, l, p, lc) => l.LabelCategoryId == lc.Id)
  505 + .LeftJoin<FlProductCategoryDbEntity>((t, l, p, lc, pc) => p.CategoryId == pc.Id)
  506 + .LeftJoin<LocationAggregateRoot>((t, l, p, lc, pc, loc) =>
  507 + t.LocationId != null && SqlFunc.ToString(loc.Id) == t.LocationId)
  508 + .Where((t, l, p, lc, pc, loc) => !loc.IsDeleted)
  509 + .WhereIF(!isAdmin, (t, l, p, lc, pc, loc) => t.CreatedBy == currentUserIdStr)
  510 + .WhereIF(locationIds is not null, (t, l, p, lc, pc, loc) => locationIds!.Contains(t.LocationId!))
  511 + .WhereIF(!string.IsNullOrWhiteSpace(keyword),
  512 + (t, l, p, lc, pc, loc) =>
  513 + (p.ProductName != null && p.ProductName.Contains(keyword!)) ||
  514 + (lc.CategoryName != null && lc.CategoryName.Contains(keyword!)) ||
  515 + (pc.CategoryName != null && pc.CategoryName.Contains(keyword!)));
  516 + }
  517 +
  518 + private static decimal CalcChangeRate(decimal current, decimal previous)
  519 + {
  520 + if (previous == 0)
  521 + {
  522 + return current > 0 ? 100m : 0m;
  523 + }
  524 +
  525 + return Math.Round((current - previous) * 100m / previous, 2);
  526 + }
  527 +
  528 + private static decimal CalcChangeRate(int current, int previous) =>
  529 + CalcChangeRate((decimal)current, (decimal)previous);
  530 +
  531 + private async Task<List<string>?> ResolveFilteredLocationIdsAsync(string? partnerId, string? groupId,
  532 + string? locationId)
  533 + {
  534 + var locId = locationId?.Trim();
  535 + if (!string.IsNullOrWhiteSpace(locId))
  536 + {
  537 + return new List<string> { locId };
  538 + }
  539 +
  540 + var gid = groupId?.Trim();
  541 + var pid = partnerId?.Trim();
  542 +
  543 + if (string.IsNullOrWhiteSpace(pid) && string.IsNullOrWhiteSpace(gid))
  544 + {
  545 + return null;
  546 + }
  547 +
  548 + var q = _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>().Where(x => !x.IsDeleted);
  549 +
  550 + if (!string.IsNullOrWhiteSpace(gid))
  551 + {
  552 + var g = await _dbContext.SqlSugarClient.Queryable<FlGroupDbEntity>()
  553 + .FirstAsync(x => !x.IsDeleted && x.Id == gid);
  554 + if (g is null)
  555 + {
  556 + return new List<string>();
  557 + }
  558 +
  559 + var gName = g.GroupName?.Trim() ?? string.Empty;
  560 + var partner = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
  561 + .FirstAsync(x => !x.IsDeleted && x.Id == g.PartnerId);
  562 + var pName = partner?.PartnerName?.Trim() ?? string.Empty;
  563 + q = q.Where(x => x.GroupName == gName && x.Partner == pName);
  564 + }
  565 + else if (!string.IsNullOrWhiteSpace(pid))
  566 + {
  567 + var partner = await _dbContext.SqlSugarClient.Queryable<FlPartnerDbEntity>()
  568 + .FirstAsync(x => !x.IsDeleted && x.Id == pid);
  569 + if (partner is null)
  570 + {
  571 + return new List<string>();
  572 + }
  573 +
  574 + var pName = partner.PartnerName?.Trim() ?? string.Empty;
  575 + q = q.Where(x => x.Partner == pName);
  576 + }
  577 +
  578 + var ids = await q.Select(x => SqlFunc.ToString(x.Id)).ToListAsync();
  579 + return ids;
  580 + }
  581 +
  582 + private static (DateTime rangeStart, DateTime rangeEndExcl) ResolveDateRange(DateTime? startDate,
  583 + DateTime? endDate)
  584 + {
  585 + var endDay = (endDate ?? DateTime.Today).Date;
  586 + var endExcl = endDay.AddDays(1);
  587 + var start = (startDate ?? endDay.AddDays(-29)).Date;
  588 + if (start >= endExcl)
  589 + {
  590 + start = endExcl.AddDays(-1);
  591 + }
  592 +
  593 + return (start, endExcl);
  594 + }
  595 +
  596 + private static PagedResultWithPageDto<ReportsPrintLogListItemDto> EmptyPrintLogPage(
  597 + ReportsPrintLogGetListInputVo input)
  598 + {
  599 + var pageSize = input.MaxResultCount <= 0 ? 0 : input.MaxResultCount;
  600 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
  601 + return new PagedResultWithPageDto<ReportsPrintLogListItemDto>
  602 + {
  603 + PageIndex = pageIndex,
  604 + PageSize = pageSize,
  605 + TotalCount = 0,
  606 + TotalPages = 0,
  607 + Items = new List<ReportsPrintLogListItemDto>()
  608 + };
  609 + }
  610 +
  611 + private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total,
  612 + List<T> items)
  613 + {
  614 + var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
  615 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
  616 + var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
  617 + return new PagedResultWithPageDto<T>
  618 + {
  619 + PageIndex = pageIndex,
  620 + PageSize = pageSize,
  621 + TotalCount = total,
  622 + TotalPages = totalPages,
  623 + Items = items
  624 + };
  625 + }
  626 +
  627 + private async Task<Dictionary<string, string>> LoadUserNameMapAsync(List<string> userIdStrings)
  628 + {
  629 + var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  630 + if (userIdStrings.Count == 0)
  631 + {
  632 + return map;
  633 + }
  634 +
  635 + var guids = userIdStrings
  636 + .Select(x => Guid.TryParse(x, out var g) ? g : (Guid?)null)
  637 + .Where(x => x.HasValue)
  638 + .Select(x => x!.Value)
  639 + .Distinct()
  640 + .ToList();
  641 + if (guids.Count == 0)
  642 + {
  643 + return map;
  644 + }
  645 +
  646 + var users = await _dbContext.SqlSugarClient.Queryable<UserAggregateRoot>()
  647 + .Where(u => !u.IsDeleted && guids.Contains(u.Id))
  648 + .Select(u => new { u.Id, u.Name, u.UserName })
  649 + .ToListAsync();
  650 +
  651 + foreach (var u in users)
  652 + {
  653 + var display = !string.IsNullOrWhiteSpace(u.Name) ? u.Name.Trim() : u.UserName.Trim();
  654 + map[u.Id.ToString()] = string.IsNullOrWhiteSpace(display) ? "无" : display;
  655 + }
  656 +
  657 + return map;
  658 + }
  659 +
  660 + private static string ResolveUserName(Dictionary<string, string> map, string? createdBy)
  661 + {
  662 + if (string.IsNullOrWhiteSpace(createdBy))
  663 + {
  664 + return "无";
  665 + }
  666 +
  667 + return map.TryGetValue(createdBy.Trim(), out var n) ? n : "无";
  668 + }
  669 +
  670 + private static string FormatLocationText(string? locName, string? locCode)
  671 + {
  672 + var n = locName?.Trim();
  673 + var c = locCode?.Trim();
  674 + if (string.IsNullOrWhiteSpace(n) && string.IsNullOrWhiteSpace(c))
  675 + {
  676 + return "无";
  677 + }
  678 +
  679 + if (string.IsNullOrWhiteSpace(c))
  680 + {
  681 + return n ?? "无";
  682 + }
  683 +
  684 + if (string.IsNullOrWhiteSpace(n))
  685 + {
  686 + return $"({c})";
  687 + }
  688 +
  689 + return $"{n} ({c})";
  690 + }
  691 +
  692 + private static string FormatTemplateDisplay(decimal w, decimal h, string? unit, string? templateName)
  693 + {
  694 + var size = FormatLabelSizeWithUnit(w, h, unit ?? "inch");
  695 + var tn = templateName?.Trim();
  696 + if (string.IsNullOrWhiteSpace(tn))
  697 + {
  698 + return size ?? "无";
  699 + }
  700 +
  701 + return string.IsNullOrWhiteSpace(size) ? tn : $"{size} {tn}";
  702 + }
  703 +
  704 + private static string? FormatLabelSizeWithUnit(decimal w, decimal h, string unit)
  705 + {
  706 + var u = (unit ?? "inch").Trim().ToLowerInvariant();
  707 + var ws = w.ToString(CultureInfo.InvariantCulture);
  708 + var hs = h.ToString(CultureInfo.InvariantCulture);
  709 + var normalizedUnit = u is "in" ? "inch" : u;
  710 + return $"{ws}x{hs}{normalizedUnit}";
  711 + }
  712 +
  713 + private static string TryExtractExpiryText(string? printInputJson)
  714 + {
  715 + if (string.IsNullOrWhiteSpace(printInputJson))
  716 + {
  717 + return "无";
  718 + }
  719 +
  720 + try
  721 + {
  722 + using var doc = JsonDocument.Parse(printInputJson);
  723 + if (doc.RootElement.ValueKind != JsonValueKind.Object)
  724 + {
  725 + return "无";
  726 + }
  727 +
  728 + foreach (var prop in doc.RootElement.EnumerateObject())
  729 + {
  730 + var key = prop.Name.Trim();
  731 + if (!key.Equals("expiryDate", StringComparison.OrdinalIgnoreCase) &&
  732 + !key.Equals("expiry", StringComparison.OrdinalIgnoreCase) &&
  733 + !key.Equals("expirationDate", StringComparison.OrdinalIgnoreCase))
  734 + {
  735 + continue;
  736 + }
  737 +
  738 + var v = prop.Value;
  739 + if (v.ValueKind == JsonValueKind.String)
  740 + {
  741 + var s = v.GetString();
  742 + return string.IsNullOrWhiteSpace(s) ? "无" : s.Trim();
  743 + }
  744 +
  745 + if (v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n))
  746 + {
  747 + return n.ToString(CultureInfo.InvariantCulture);
  748 + }
  749 +
  750 + return v.ToString();
  751 + }
  752 + }
  753 + catch
  754 + {
  755 + return "无";
  756 + }
  757 +
  758 + return "无";
  759 + }
  760 +
  761 + private static IActionResult BuildEmptyPdf(string fileName)
  762 + {
  763 + QuestPDF.Settings.License = LicenseType.Community;
  764 + var document = Document.Create(c =>
  765 + {
  766 + c.Page(p =>
  767 + {
  768 + p.Margin(30);
  769 + p.Content().Text("No data for current filters.");
  770 + });
  771 + });
  772 + var ms = new MemoryStream();
  773 + document.GeneratePdf(ms);
  774 + ms.Position = 0;
  775 + return new FileStreamResult(ms, "application/pdf") { FileDownloadName = fileName };
  776 + }
  777 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs
1 1 using System;
2 2 using System.Collections.Generic;
  3 +using System.Globalization;
3 4 using System.IdentityModel.Tokens.Jwt;
4 5 using System.Linq;
5 6 using System.Security.Claims;
... ... @@ -20,6 +21,7 @@ using Volo.Abp;
20 21 using Volo.Abp.Application.Services;
21 22 using Volo.Abp.EventBus.Local;
22 23 using Volo.Abp.Security.Claims;
  24 +using Volo.Abp.Uow;
23 25 using Volo.Abp.Users;
24 26 using Yi.Framework.Core.Helper;
25 27 using Yi.Framework.Rbac.Domain.Entities;
... ... @@ -135,6 +137,267 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
135 137 return await LoadBoundLocationsAsync(CurrentUser.Id.Value);
136 138 }
137 139  
  140 + /// <summary>
  141 + /// 查询单个门店详情(Location 页):地址、门店电话、营业时间占位、店长(角色含 manager 的绑定用户)
  142 + /// </summary>
  143 + /// <remarks>
  144 + /// 仅当当前登录用户在 <c>userlocation</c> 中绑定该 <c>locationId</c> 时可查;否则返回业务异常。
  145 + ///
  146 + /// 店长:在同店绑定用户中,取 <c>Role.RoleCode</c> 或 <c>Role.RoleName</c>(忽略大小写)包含 <c>manager</c> 的第一条;
  147 + /// 若无匹配则店长姓名与电话均为「无」。
  148 + ///
  149 + /// <c>OperatingHours</c>:当前 <c>location</c> 表无营业时间字段,固定返回「无」。
  150 + /// </remarks>
  151 + /// <param name="locationId">门店主键(Guid 字符串)</param>
  152 + /// <returns>与原型一致的展示字段</returns>
  153 + /// <response code="200">成功</response>
  154 + /// <response code="400">未登录、门店标识无效、未绑定或门店不存在</response>
  155 + /// <response code="500">服务器错误</response>
  156 + [Authorize]
  157 + public virtual async Task<UsAppLocationDetailOutputDto> GetLocationDetailAsync(string locationId)
  158 + {
  159 + if (!CurrentUser.Id.HasValue)
  160 + {
  161 + throw new UserFriendlyException("用户未登录");
  162 + }
  163 +
  164 + var lid = (locationId ?? string.Empty).Trim();
  165 + if (string.IsNullOrEmpty(lid) || !Guid.TryParse(lid, out var locationGuid))
  166 + {
  167 + throw new UserFriendlyException("无效的门店标识");
  168 + }
  169 +
  170 + var userIdStr = CurrentUser.Id.Value.ToString();
  171 + var bound = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  172 + .AnyAsync(x => !x.IsDeleted && x.UserId == userIdStr && x.LocationId == lid);
  173 + if (!bound)
  174 + {
  175 + throw new UserFriendlyException("当前账号未绑定该门店,无法查看");
  176 + }
  177 +
  178 + var locRows = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  179 + .Where(x => !x.IsDeleted && x.Id == locationGuid)
  180 + .ToListAsync();
  181 + var loc = locRows.FirstOrDefault();
  182 + if (loc is null)
  183 + {
  184 + throw new UserFriendlyException("门店不存在或已删除");
  185 + }
  186 +
  187 + var (mgrName, mgrPhone) = await TryResolveStoreManagerAsync(lid);
  188 +
  189 + return new UsAppLocationDetailOutputDto
  190 + {
  191 + LocationId = loc.Id.ToString(),
  192 + LocationName = string.IsNullOrWhiteSpace(loc.LocationName) ? "无" : loc.LocationName.Trim(),
  193 + FullAddress = BuildFullAddress(loc),
  194 + StorePhone = FormatStorePhoneDisplay(loc.Phone),
  195 + OperatingHours = "无",
  196 + ManagerName = mgrName,
  197 + ManagerPhone = mgrPhone
  198 + };
  199 + }
  200 +
  201 + /// <inheritdoc />
  202 + [Authorize]
  203 + public virtual async Task<UsAppMyProfileOutputDto> GetMyProfileAsync()
  204 + {
  205 + if (!CurrentUser.Id.HasValue)
  206 + {
  207 + throw new UserFriendlyException("用户未登录");
  208 + }
  209 +
  210 + var userId = CurrentUser.Id.Value;
  211 + var user = await _userRepository.GetByIdAsync(userId);
  212 + if (user is null || user.IsDeleted || !user.State)
  213 + {
  214 + throw new UserFriendlyException("用户不存在或已停用");
  215 + }
  216 +
  217 + var roleRows = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity, RoleAggregateRoot>((ur, r) => ur.RoleId == r.Id)
  218 + .Where((ur, r) => ur.UserId == userId && !r.IsDeleted && r.State)
  219 + .OrderBy((ur, r) => r.OrderNum)
  220 + .Select((ur, r) => new { r.RoleName, r.RoleCode })
  221 + .ToListAsync();
  222 +
  223 + var roleNames = roleRows.Select(x => x.RoleName?.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
  224 + var roleDisplay = roleNames.Count == 0 ? "无" : string.Join(", ", roleNames);
  225 + var primaryCode = roleRows.FirstOrDefault()?.RoleCode?.Trim();
  226 +
  227 + var fullName = !string.IsNullOrWhiteSpace(user.Name?.Trim())
  228 + ? user.Name.Trim()
  229 + : (!string.IsNullOrWhiteSpace(user.Nick?.Trim()) ? user.Nick.Trim() : user.UserName.Trim());
  230 +
  231 + return new UsAppMyProfileOutputDto
  232 + {
  233 + FullName = fullName,
  234 + Email = string.IsNullOrWhiteSpace(user.Email) ? "无" : user.Email.Trim(),
  235 + Phone = FormatPhoneDisplay(user.Phone),
  236 + EmployeeId = string.IsNullOrWhiteSpace(user.UserName) ? "无" : user.UserName.Trim(),
  237 + RoleDisplay = roleDisplay,
  238 + PrimaryRoleCode = string.IsNullOrWhiteSpace(primaryCode) ? null : primaryCode
  239 + };
  240 + }
  241 +
  242 + /// <inheritdoc />
  243 + [Authorize]
  244 + [UnitOfWork]
  245 + public virtual async Task ChangePasswordAsync(UsAppChangePasswordInputVo input)
  246 + {
  247 + if (input is null)
  248 + {
  249 + throw new UserFriendlyException("入参不能为空");
  250 + }
  251 +
  252 + if (!CurrentUser.Id.HasValue)
  253 + {
  254 + throw new UserFriendlyException("用户未登录");
  255 + }
  256 +
  257 + var current = input.CurrentPassword ?? string.Empty;
  258 + var newPwd = input.NewPassword ?? string.Empty;
  259 + var confirm = input.ConfirmNewPassword ?? string.Empty;
  260 +
  261 + if (string.IsNullOrWhiteSpace(current) || string.IsNullOrWhiteSpace(newPwd) || string.IsNullOrWhiteSpace(confirm))
  262 + {
  263 + throw new UserFriendlyException("请填写当前密码、新密码与确认密码");
  264 + }
  265 +
  266 + if (!string.Equals(newPwd, confirm, StringComparison.Ordinal))
  267 + {
  268 + throw new UserFriendlyException("新密码与确认密码不一致");
  269 + }
  270 +
  271 + if (string.Equals(current, newPwd, StringComparison.Ordinal))
  272 + {
  273 + throw new UserFriendlyException("新密码不能与当前密码相同");
  274 + }
  275 +
  276 + ValidateAppPasswordComplexity(newPwd);
  277 +
  278 + var userId = CurrentUser.Id.Value;
  279 + var user = await _userRepository.GetByIdAsync(userId);
  280 + if (user is null || user.IsDeleted || !user.State)
  281 + {
  282 + throw new UserFriendlyException("用户不存在或已停用");
  283 + }
  284 +
  285 + if (!user.JudgePassword(current))
  286 + {
  287 + throw new UserFriendlyException(UserConst.Login_Error);
  288 + }
  289 +
  290 + user.EncryPassword.Password = newPwd;
  291 + user.BuildPassword();
  292 + await _userRepository.UpdateAsync(user);
  293 + }
  294 +
  295 + private static string FormatPhoneDisplay(long? phone)
  296 + {
  297 + if (!phone.HasValue)
  298 + {
  299 + return "无";
  300 + }
  301 +
  302 + var digits = Math.Abs(phone.Value).ToString(CultureInfo.InvariantCulture);
  303 + if (digits.Length == 10)
  304 + {
  305 + return $"+1 ({digits[..3]}) {digits.Substring(3, 3)}-{digits.Substring(6, 4)}";
  306 + }
  307 +
  308 + if (digits.Length == 11 && digits.StartsWith("1", StringComparison.Ordinal))
  309 + {
  310 + return $"+1 ({digits[1..4]}) {digits.Substring(4, 3)}-{digits.Substring(7, 4)}";
  311 + }
  312 +
  313 + return $"+{digits}";
  314 + }
  315 +
  316 + private static string FormatStorePhoneDisplay(string? phone)
  317 + {
  318 + var t = phone?.Trim();
  319 + return string.IsNullOrEmpty(t) ? "无" : t;
  320 + }
  321 +
  322 + private async Task<(string Name, string Phone)> TryResolveStoreManagerAsync(string locationIdTrimmed)
  323 + {
  324 + var userIdStrings = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  325 + .Where(x => !x.IsDeleted && x.LocationId == locationIdTrimmed)
  326 + .Select(x => x.UserId)
  327 + .Distinct()
  328 + .ToListAsync();
  329 +
  330 + var userGuids = userIdStrings
  331 + .Select(s => Guid.TryParse(s, out var g) ? (Guid?)g : null)
  332 + .Where(g => g.HasValue)
  333 + .Select(g => g!.Value)
  334 + .ToList();
  335 +
  336 + if (userGuids.Count == 0)
  337 + {
  338 + return ("无", "无");
  339 + }
  340 +
  341 + var rows = await _dbContext.SqlSugarClient.Queryable<UserAggregateRoot, UserRoleEntity, RoleAggregateRoot>(
  342 + (u, ur, r) => u.Id == ur.UserId && ur.RoleId == r.Id)
  343 + .Where((u, ur, r) => userGuids.Contains(u.Id) && !u.IsDeleted && u.State)
  344 + .Where((u, ur, r) => !r.IsDeleted && r.State)
  345 + .Where((u, ur, r) =>
  346 + SqlFunc.ToLower(r.RoleCode).Contains("manager") ||
  347 + SqlFunc.ToLower(r.RoleName).Contains("manager"))
  348 + .OrderBy((u, ur, r) => u.Name)
  349 + .Select((u, ur, r) => new
  350 + {
  351 + u.Name,
  352 + u.Nick,
  353 + u.UserName,
  354 + u.Phone
  355 + })
  356 + .ToListAsync();
  357 +
  358 + var row = rows.FirstOrDefault();
  359 + if (row is null)
  360 + {
  361 + return ("无", "无");
  362 + }
  363 +
  364 + var displayName = !string.IsNullOrWhiteSpace(row.Name?.Trim())
  365 + ? row.Name!.Trim()
  366 + : (!string.IsNullOrWhiteSpace(row.Nick?.Trim())
  367 + ? row.Nick!.Trim()
  368 + : (row.UserName?.Trim() ?? "无"));
  369 +
  370 + return (displayName, FormatPhoneDisplay(row.Phone));
  371 + }
  372 +
  373 + private static void ValidateAppPasswordComplexity(string password)
  374 + {
  375 + if (password.Length < 8)
  376 + {
  377 + throw new UserFriendlyException("新密码至少 8 位");
  378 + }
  379 +
  380 + if (!password.Any(char.IsUpper))
  381 + {
  382 + throw new UserFriendlyException("新密码需包含大写字母");
  383 + }
  384 +
  385 + if (!password.Any(char.IsLower))
  386 + {
  387 + throw new UserFriendlyException("新密码需包含小写字母");
  388 + }
  389 +
  390 + if (!password.Any(char.IsDigit))
  391 + {
  392 + throw new UserFriendlyException("新密码需包含至少一个数字");
  393 + }
  394 +
  395 + if (!password.Any(c => !char.IsLetterOrDigit(c)))
  396 + {
  397 + throw new UserFriendlyException("新密码需包含至少一个特殊字符");
  398 + }
  399 + }
  400 +
138 401 private void ValidationImageCaptcha(string? uuid, string? code)
139 402 {
140 403 if (!_rbacOptions.EnableCaptcha)
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
... ... @@ -613,8 +613,9 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
613 613 throw new UserFriendlyException("打印任务不存在");
614 614 }
615 615  
616   - // 仅允许重打自己在当前门店的任务
617   - if (!string.Equals(old.CreatedBy?.Trim(), currentUserId, StringComparison.OrdinalIgnoreCase))
  616 + // 非 admin:仅允许重打自己在当前门店的任务;admin 可重打任意用户任务(仍须门店一致)
  617 + var isAdmin = ReportsRoleHelper.IsAdminRole(CurrentUser);
  618 + if (!isAdmin && !string.Equals(old.CreatedBy?.Trim(), currentUserId, StringComparison.OrdinalIgnoreCase))
618 619 {
619 620 throw new UserFriendlyException("无权限重打该任务");
620 621 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_group_create.sql 0 → 100644
  1 +-- 组织/分组(Group),归属合作伙伴(Parent Partner)
  2 +-- 依赖:请先执行 fl_partner_create.sql,保证存在 fl_partner 表。
  3 +
  4 +CREATE TABLE IF NOT EXISTS `fl_group` (
  5 + `Id` varchar(64) NOT NULL COMMENT '主键',
  6 + `IsDeleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
  7 + `CreationTime` datetime(6) NOT NULL COMMENT '创建时间',
  8 + `CreatorId` varchar(64) DEFAULT NULL COMMENT '创建人',
  9 + `LastModificationTime` datetime(6) DEFAULT NULL COMMENT '最后修改时间',
  10 + `LastModifierId` varchar(64) DEFAULT NULL COMMENT '最后修改人',
  11 + `GroupName` varchar(256) NOT NULL COMMENT '组织名称',
  12 + `PartnerId` varchar(64) NOT NULL COMMENT '所属合作伙伴 fl_partner.Id',
  13 + `State` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
  14 + PRIMARY KEY (`Id`),
  15 + KEY `IX_fl_group_IsDeleted` (`IsDeleted`),
  16 + KEY `IX_fl_group_State` (`State`),
  17 + KEY `IX_fl_group_PartnerId` (`PartnerId`),
  18 + KEY `IX_fl_group_GroupName` (`GroupName`(128)),
  19 + KEY `IX_fl_group_CreationTime` (`CreationTime`),
  20 + CONSTRAINT `FK_fl_group_partner` FOREIGN KEY (`PartnerId`) REFERENCES `fl_partner` (`Id`)
  21 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织(Group)';
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql 0 → 100644
  1 +-- 合作伙伴主数据(美国版 antis-foodlabeling-us)
  2 +-- 执行前请确认连接库为业务库;若表已存在请勿重复执行。
  3 +
  4 +CREATE TABLE IF NOT EXISTS `fl_partner` (
  5 + `Id` varchar(64) NOT NULL COMMENT '主键',
  6 + `IsDeleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
  7 + `CreationTime` datetime(6) NOT NULL COMMENT '创建时间',
  8 + `CreatorId` varchar(64) DEFAULT NULL COMMENT '创建人',
  9 + `LastModificationTime` datetime(6) DEFAULT NULL COMMENT '最后修改时间',
  10 + `LastModifierId` varchar(64) DEFAULT NULL COMMENT '最后修改人',
  11 + `PartnerName` varchar(256) NOT NULL COMMENT '合作伙伴名称',
  12 + `ContactEmail` varchar(256) DEFAULT NULL COMMENT '联系邮箱',
  13 + `PhoneNumber` varchar(64) DEFAULT NULL COMMENT '电话',
  14 + `State` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
  15 + PRIMARY KEY (`Id`),
  16 + KEY `IX_fl_partner_IsDeleted` (`IsDeleted`),
  17 + KEY `IX_fl_partner_State` (`State`),
  18 + KEY `IX_fl_partner_CreationTime` (`CreationTime`),
  19 + KEY `IX_fl_partner_PartnerName` (`PartnerName`(128))
  20 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合作伙伴';
... ...
项目相关文档/Dashboard统计接口对接说明.md
... ... @@ -28,6 +28,7 @@
28 28 "weeklyPrintVolume": [],
29 29 "byCategory": [],
30 30 "byCategoryTotal": 0,
  31 + "recentLabels": [],
31 32 "generatedAt": "2026-04-22T10:00:00+08:00",
32 33  
33 34 "metricCards": [],
... ... @@ -39,6 +40,7 @@
39 40 说明:
40 41 - `labelsPrintedToday/activeTemplates/...`、`byCategory/byCategoryTotal` 是**前端直观命名**(推荐使用)。
41 42 - `metricCards`、`categoryDistribution`、`categoryDistributionTotal` 为**兼容字段**(与旧版返回一致)。
  43 +- `recentLabels`:**Recent Labels** 区块数据,全门店按打印时间倒序取最新 **10** 条(`fl_label_print_task`)。
42 44  
43 45 ---
44 46  
... ... @@ -88,6 +90,25 @@
88 90  
89 91 ---
90 92  
  93 +### 3.4 最近打印标签(`recentLabels`)
  94 +
  95 +数组元素类型:`DashboardRecentLabelItemDto`,按 **`PrintedAt`(`PrintedAt` 为空则用 `CreationTime`)倒序**,最多 **10** 条。
  96 +
  97 +| 字段 | 类型 | 说明 |
  98 +|---|---|---|
  99 +| `taskId` | string | 打印任务 Id(`fl_label_print_task.Id`) |
  100 +| `labelCode` | string | 标签编码(界面 Serial,如 `1-251201`) |
  101 +| `displayName` | string | 展示标题:优先 **产品名**,否则 **标签名** |
  102 +| `printedByUserId` | string \| null | 打印人用户 Id(`CreatedBy`) |
  103 +| `printedByName` | string | 打印人展示名(`User.Name` 或 `UserName`) |
  104 +| `printedAt` | string (datetime) | 打印时间(ISO 8601) |
  105 +| `status` | string | `active` 或 `expired`:从 `PrintInputJson` 解析 `expiryDate` / `expiry` / `expirationDate`,与**当天日期**比较;无保质期或解析失败视为 `active` |
  106 +| `labelTypeBadge` | string | 模板尺寸短文案(如 `2"x2"`),用于左侧圆标 |
  107 +
  108 +前端可用 `printedAt` 自行格式化为「10 mins ago」等相对时间。
  109 +
  110 +---
  111 +
91 112 ## 4. 返回示例
92 113  
93 114 ```json
... ... @@ -155,6 +176,18 @@
155 176 { "categoryId": "CAT003", "categoryName": "Dinner", "count": 230, "ratio": 23.00 }
156 177 ],
157 178 "byCategoryTotal": 1000,
  179 + "recentLabels": [
  180 + {
  181 + "taskId": "…",
  182 + "labelCode": "1-251201",
  183 + "displayName": "Chicken Breast",
  184 + "printedByUserId": "…",
  185 + "printedByName": "Alice J.",
  186 + "printedAt": "2026-04-22T09:50:00+08:00",
  187 + "status": "active",
  188 + "labelTypeBadge": "2\"x2\""
  189 + }
  190 + ],
158 191 "generatedAt": "2026-04-22T10:00:00+08:00",
159 192 "metricCards": [],
160 193 "categoryDistribution": [],
... ...
项目相关文档/合作伙伴Partner接口对接说明.md 0 → 100644
  1 +# 合作伙伴(Partner)与组织(Group)接口对接说明
  2 +
  3 +> 适用范围:美国版 Web 管理端「Account Management」下的 **Partner**、**Group** 主数据
  4 +> **Partner** 表:`fl_partner`,接口:`IPartnerAppService` / `PartnerAppService`
  5 +> **Group** 表:`fl_group`(`PartnerId` 关联 `fl_partner.Id`),接口:`IGroupAppService` / `GroupAppService`
  6 +> 宿主路由前缀:`/api/app`(与 `YiAbpWebModule` 中 `RootPath = api/app` 一致)
  7 +
  8 +---
  9 +
  10 +## 0. 通用说明
  11 +
  12 +- **鉴权**:需要登录(`Authorization: Bearer {token}`),与其它 `/api/app/*` 接口一致。
  13 +- **Content-Type**:`POST` / `PUT` 使用 `application/json`。
  14 +- **分页约定(美国版食品标签模块)**:`skipCount` 表示**页码(从 1 起)**,不是 0 基 offset;第一页请传 `skipCount=1`。与 `PagedQueryConvention` 及 SqlSugar `ToPageListAsync` 用法一致。
  15 +- **逻辑删除**:`DELETE` 将对应表的 `IsDeleted` 置为 `true`(`fl_partner` / `fl_group`),不物理删行。
  16 +- **列表与导出筛选一致**:各模块列表的筛选字段与对应 **export-pdf** 接口一致,便于数据对齐。
  17 +- **Group 与 Partner**:新建/编辑 Group 时 `partnerId` 必须指向**未逻辑删除**的 `fl_partner`;列表左联 Partner 时仅展示未删除合作伙伴名称,已删 Partner 在列表中显示为 **`无`**。
  18 +
  19 +---
  20 +
  21 +# 第一部分:Partner(合作伙伴)
  22 +
  23 +## 1. 分页列表
  24 +
  25 +- **方法**:`GET`
  26 +- **路径**:`/api/app/partner`
  27 +
  28 +### 1.1 查询参数(`PartnerGetListInputVo`)
  29 +
  30 +| 参数 | 类型 | 必填 | 说明 |
  31 +|------|------|------|------|
  32 +| `skipCount` | int | 是 | 页码,从 **1** 开始 |
  33 +| `maxResultCount` | int | 是 | 每页条数 |
  34 +| `sorting` | string | 否 | 排序,仅支持白名单(见下) |
  35 +| `keyword` | string | 否 | 模糊匹配 `PartnerName`、`ContactEmail`、`PhoneNumber` |
  36 +| `state` | bool | 否 | 按启用状态筛选;不传则不过滤 |
  37 +
  38 +**排序白名单**(大小写不敏感):
  39 +
  40 +- `PartnerName asc` / `PartnerName desc`
  41 +- `CreationTime asc` / `CreationTime desc`
  42 +- `State asc` / `State desc`
  43 +
  44 +其它值将回退为默认:**按 `CreationTime` 降序**。
  45 +
  46 +### 1.2 请求示例
  47 +
  48 +```http
  49 +GET /api/app/partner?skipCount=1&maxResultCount=10&keyword=Global&state=true&sorting=CreationTime%20desc HTTP/1.1
  50 +Authorization: Bearer {token}
  51 +```
  52 +
  53 +### 1.3 响应结构(`PagedResultWithPageDto<PartnerGetListOutputDto>`)
  54 +
  55 +```json
  56 +{
  57 + "pageIndex": 1,
  58 + "pageSize": 10,
  59 + "totalCount": 2,
  60 + "totalPages": 1,
  61 + "items": [
  62 + {
  63 + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  64 + "partnerName": "Global Foods Inc.",
  65 + "contactEmail": "admin@globalfoods.com",
  66 + "phoneNumber": "+1 (555) 100-2000",
  67 + "state": true,
  68 + "creationTime": "2026-04-27T10:00:00"
  69 + }
  70 + ]
  71 +}
  72 +```
  73 +
  74 +### 1.4 列表项字段(`PartnerGetListOutputDto`)
  75 +
  76 +| 字段 | 类型 | 说明 |
  77 +|------|------|------|
  78 +| `id` | string | 主键 |
  79 +| `partnerName` | string | 合作伙伴名称 |
  80 +| `contactEmail` | string \| null | 联系邮箱 |
  81 +| `phoneNumber` | string \| null | 电话 |
  82 +| `state` | bool | 是否启用(UI Active 开关) |
  83 +| `creationTime` | string (datetime) | 创建时间 |
  84 +
  85 +---
  86 +
  87 +## 2. 详情
  88 +
  89 +- **方法**:`GET`
  90 +- **路径**:`/api/app/partner/{id}`
  91 +
  92 +### 2.1 路径参数
  93 +
  94 +| 参数 | 说明 |
  95 +|------|------|
  96 +| `id` | `fl_partner.Id` |
  97 +
  98 +### 2.2 响应结构(`PartnerGetOutputDto`)
  99 +
  100 +| 字段 | 类型 | 说明 |
  101 +|------|------|------|
  102 +| `id` | string | 主键 |
  103 +| `partnerName` | string | 合作伙伴名称 |
  104 +| `contactEmail` | string \| null | 联系邮箱 |
  105 +| `phoneNumber` | string \| null | 电话 |
  106 +| `state` | bool | 是否启用 |
  107 +| `creationTime` | string (datetime) | 创建时间 |
  108 +| `lastModificationTime` | string (datetime) \| null | 最后修改时间 |
  109 +
  110 +### 2.3 错误说明
  111 +
  112 +- `id` 为空或记录不存在(含已逻辑删除):业务错误提示「合作伙伴不存在」等。
  113 +
  114 +---
  115 +
  116 +## 3. 新增
  117 +
  118 +- **方法**:`POST`
  119 +- **路径**:`/api/app/partner`
  120 +
  121 +### 3.1 Body(`PartnerCreateInputVo`)
  122 +
  123 +```json
  124 +{
  125 + "partnerName": "Global Foods Inc.",
  126 + "contactEmail": "admin@globalfoods.com",
  127 + "phoneNumber": "+1 (555) 100-2000",
  128 + "state": true
  129 +}
  130 +```
  131 +
  132 +| 字段 | 类型 | 必填 | 说明 |
  133 +|------|------|------|------|
  134 +| `partnerName` | string | 是 | 合作伙伴名称,去首尾空格后不能为空 |
  135 +| `contactEmail` | string | 否 | 若填写则做简单格式校验(含 `@` 等) |
  136 +| `phoneNumber` | string | 否 | 电话 |
  137 +| `state` | bool | 否 | 默认 `true` |
  138 +
  139 +### 3.2 响应
  140 +
  141 +- 成功:返回 `PartnerGetOutputDto`(与详情结构一致)。
  142 +
  143 +---
  144 +
  145 +## 4. 编辑
  146 +
  147 +- **方法**:`PUT`
  148 +- **路径**:`/api/app/partner/{id}`
  149 +
  150 +### 4.1 参数
  151 +
  152 +- **Path**:`id` 为当前合作伙伴主键。
  153 +- **Body**:`PartnerUpdateInputVo`,字段与新增相同。
  154 +
  155 +```json
  156 +{
  157 + "partnerName": "Global Foods Inc.",
  158 + "contactEmail": "admin@globalfoods.com",
  159 + "phoneNumber": "+1 (555) 100-2000",
  160 + "state": false
  161 +}
  162 +```
  163 +
  164 +### 4.2 响应
  165 +
  166 +- 成功:返回更新后的 `PartnerGetOutputDto`。
  167 +
  168 +---
  169 +
  170 +## 5. 删除(逻辑删除)
  171 +
  172 +- **方法**:`DELETE`
  173 +- **路径**:`/api/app/partner/{id}`
  174 +
  175 +### 5.1 路径参数
  176 +
  177 +| 参数 | 说明 |
  178 +|------|------|
  179 +| `id` | `fl_partner.Id` |
  180 +
  181 +### 5.2 行为
  182 +
  183 +- 将 `IsDeleted` 置为 `true`,并更新 `LastModificationTime` / `LastModifierId`(若当前用户存在)。
  184 +
  185 +---
  186 +
  187 +## 6. 批量导出 PDF
  188 +
  189 +- **方法**:`GET`
  190 +- **路径**:`/api/app/partner/export-pdf`
  191 +- **响应**:`Content-Type: application/pdf`,附件名形如 `partners_yyyy-MM-dd_HH-mm-ss.pdf`
  192 +
  193 +### 6.1 查询参数
  194 +
  195 +与列表接口相同的筛选字段(分页字段可忽略):
  196 +
  197 +| 参数 | 类型 | 必填 | 说明 |
  198 +|------|------|------|------|
  199 +| `keyword` | string | 否 | 与列表 `keyword` 一致 |
  200 +| `state` | bool | 否 | 与列表 `state` 一致 |
  201 +| `sorting` | string | 否 | 与列表白名单一致,用于导出行顺序 |
  202 +
  203 +### 6.2 限制
  204 +
  205 +- 命中行数 **超过 5000** 时接口返回业务错误,需缩小筛选范围后再导出。
  206 +- 导出最多取 **5000** 条,排序与列表查询逻辑一致。
  207 +
  208 +### 6.3 请求示例
  209 +
  210 +```http
  211 +GET /api/app/partner/export-pdf?keyword=Global&state=true HTTP/1.1
  212 +Authorization: Bearer {token}
  213 +```
  214 +
  215 +### 6.4 PDF 内容说明
  216 +
  217 +- 表头列:**Partner**、**Contact**、**Phone**、**Status**、**Created**。
  218 +- `Status` 文本:`state === true` 时为 `active`,否则 `inactive`。
  219 +- 空邮箱、空电话在 PDF 中显示为 **`无`**(与项目列表空值展示约定一致)。
  220 +
  221 +---
  222 +
  223 +## 7. 数据库与建表(Partner)
  224 +
  225 +- 建表脚本:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql`
  226 +- 主要字段:`Id`、`IsDeleted`、`CreationTime`、`CreatorId`、`LastModificationTime`、`LastModifierId`、`PartnerName`、`ContactEmail`、`PhoneNumber`、`State`
  227 +
  228 +---
  229 +
  230 +## 8. 与门店字段的关系说明
  231 +
  232 +- 门店(`location`)上可能存在 **`Partner` 字符串字段**(原型/筛选用),与本文 **`fl_partner` 主数据表** 无强制外键关联。
  233 +- 若后续要将门店关联到合作伙伴主数据,需单独产品方案(例如增加 `PartnerId` 或同步名称)。
  234 +
  235 +---
  236 +
  237 +## 9. 前端对接提示(Partner)
  238 +
  239 +- 列表「Search」对应 `keyword`;「Active」筛选对应 `state`。
  240 +- 「Bulk Export (PDF)」调用 **第 6 节** 导出接口,查询参数与当前列表筛选保持一致即可。
  241 +
  242 +---
  243 +
  244 +# 第二部分:Group(组织 / Group)
  245 +
  246 +> UI:**Group Name**、**Parent Partner**(下拉绑定合作伙伴)、**Status**、**Bulk Export (PDF)**、**New+** 弹窗(Group Name、Assign to Partner、Active)。
  247 +
  248 +## 10. 数据库与建表(Group)
  249 +
  250 +- 库中原先**无**独立 Group 业务表;新建表名:`fl_group`。
  251 +- 建表脚本:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_group_create.sql`
  252 +- **须先存在 `fl_partner` 表**(脚本内含外键 `FK_fl_group_partner` → `fl_partner(Id)`)。
  253 +- 主要字段:`Id`、`IsDeleted`、`CreationTime`、`CreatorId`、`LastModificationTime`、`LastModifierId`、`GroupName`、`PartnerId`、`State`
  254 +
  255 +**建表 SQL(与脚本文件一致,便于直接执行):**
  256 +
  257 +```sql
  258 +CREATE TABLE IF NOT EXISTS `fl_group` (
  259 + `Id` varchar(64) NOT NULL COMMENT '主键',
  260 + `IsDeleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
  261 + `CreationTime` datetime(6) NOT NULL COMMENT '创建时间',
  262 + `CreatorId` varchar(64) DEFAULT NULL COMMENT '创建人',
  263 + `LastModificationTime` datetime(6) DEFAULT NULL COMMENT '最后修改时间',
  264 + `LastModifierId` varchar(64) DEFAULT NULL COMMENT '最后修改人',
  265 + `GroupName` varchar(256) NOT NULL COMMENT '组织名称',
  266 + `PartnerId` varchar(64) NOT NULL COMMENT '所属合作伙伴 fl_partner.Id',
  267 + `State` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
  268 + PRIMARY KEY (`Id`),
  269 + KEY `IX_fl_group_IsDeleted` (`IsDeleted`),
  270 + KEY `IX_fl_group_State` (`State`),
  271 + KEY `IX_fl_group_PartnerId` (`PartnerId`),
  272 + KEY `IX_fl_group_GroupName` (`GroupName`(128)),
  273 + KEY `IX_fl_group_CreationTime` (`CreationTime`),
  274 + CONSTRAINT `FK_fl_group_partner` FOREIGN KEY (`PartnerId`) REFERENCES `fl_partner` (`Id`)
  275 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织(Group)';
  276 +```
  277 +
  278 +---
  279 +
  280 +## 11. Group — 分页列表
  281 +
  282 +- **方法**:`GET`
  283 +- **路径**:`/api/app/group`
  284 +
  285 +### 11.1 查询参数(`GroupGetListInputVo`)
  286 +
  287 +| 参数 | 类型 | 必填 | 说明 |
  288 +|------|------|------|------|
  289 +| `skipCount` | int | 是 | 页码,从 **1** 开始 |
  290 +| `maxResultCount` | int | 是 | 每页条数 |
  291 +| `sorting` | string | 否 | 排序白名单(见下) |
  292 +| `keyword` | string | 否 | 模糊匹配 `GroupName`、所属 **未删除** Partner 的 `PartnerName` |
  293 +| `partnerId` | string | 否 | 仅查看某合作伙伴下的组织(`fl_partner.Id`) |
  294 +| `state` | bool | 否 | 按启用状态筛选;不传则不过滤 |
  295 +
  296 +**排序白名单**(大小写不敏感):
  297 +
  298 +- `GroupName asc` / `GroupName desc`
  299 +- `CreationTime asc` / `CreationTime desc`
  300 +- `State asc` / `State desc`
  301 +- `PartnerName asc` / `PartnerName desc`(按关联合作伙伴名称)
  302 +
  303 +其它值回退为默认:**按 `CreationTime` 降序**。
  304 +
  305 +### 11.2 请求示例
  306 +
  307 +```http
  308 +GET /api/app/group?skipCount=1&maxResultCount=10&keyword=West&partnerId={partnerGuid}&state=true HTTP/1.1
  309 +Authorization: Bearer {token}
  310 +```
  311 +
  312 +### 11.3 响应结构(`PagedResultWithPageDto<GroupGetListOutputDto>`)
  313 +
  314 +```json
  315 +{
  316 + "pageIndex": 1,
  317 + "pageSize": 10,
  318 + "totalCount": 2,
  319 + "totalPages": 1,
  320 + "items": [
  321 + {
  322 + "id": "…",
  323 + "groupName": "West Coast Region",
  324 + "partnerId": "…",
  325 + "partnerName": "Global Foods Inc.",
  326 + "state": true,
  327 + "creationTime": "2026-04-27T10:00:00"
  328 + }
  329 + ]
  330 +}
  331 +```
  332 +
  333 +### 11.4 列表项字段
  334 +
  335 +| 字段 | 类型 | 说明 |
  336 +|------|------|------|
  337 +| `id` | string | 主键 |
  338 +| `groupName` | string | 组织名称 |
  339 +| `partnerId` | string | 所属合作伙伴 Id |
  340 +| `partnerName` | string | 父级合作伙伴名称(UI「Parent Partner」) |
  341 +| `state` | bool | 是否启用 |
  342 +| `creationTime` | string (datetime) | 创建时间 |
  343 +
  344 +---
  345 +
  346 +## 12. Group — 详情
  347 +
  348 +- **方法**:`GET`
  349 +- **路径**:`/api/app/group/{id}`
  350 +
  351 +路径参数 `id`:`fl_group.Id`。响应为 `GroupGetOutputDto`(在列表字段基础上增加 `lastModificationTime`)。
  352 +
  353 +---
  354 +
  355 +## 13. Group — 新增
  356 +
  357 +- **方法**:`POST`
  358 +- **路径**:`/api/app/group`
  359 +
  360 +### Body(`GroupCreateInputVo`)
  361 +
  362 +```json
  363 +{
  364 + "groupName": "West Coast Region",
  365 + "partnerId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  366 + "state": true
  367 +}
  368 +```
  369 +
  370 +| 字段 | 类型 | 必填 | 说明 |
  371 +|------|------|------|------|
  372 +| `groupName` | string | 是 | 组织名称 |
  373 +| `partnerId` | string | 是 | Assign to Partner,须为未删除的 `fl_partner.Id` |
  374 +| `state` | bool | 否 | 默认 `true` |
  375 +
  376 +---
  377 +
  378 +## 14. Group — 编辑
  379 +
  380 +- **方法**:`PUT`
  381 +- **路径**:`/api/app/group/{id}`
  382 +- **Body**:`GroupUpdateInputVo`(字段同新增)
  383 +
  384 +---
  385 +
  386 +## 15. Group — 删除(逻辑删除)
  387 +
  388 +- **方法**:`DELETE`
  389 +- **路径**:`/api/app/group/{id}`
  390 +
  391 +---
  392 +
  393 +## 16. Group — 批量导出 PDF
  394 +
  395 +- **方法**:`GET`
  396 +- **路径**:`/api/app/group/export-pdf`
  397 +- **响应**:`application/pdf`,附件名形如 `groups_yyyy-MM-dd_HH-mm-ss.pdf`
  398 +
  399 +### 16.1 查询参数
  400 +
  401 +与列表一致(分页可忽略):`keyword`、`partnerId`、`state`、`sorting`。
  402 +
  403 +### 16.2 限制
  404 +
  405 +- 命中行数 **超过 5000** 返回业务错误;导出最多 **5000** 条。
  406 +
  407 +### 16.3 PDF 列
  408 +
  409 +**Group Name**、**Parent Partner**、**Status**(`active` / `inactive`)、**Created**。
  410 +
  411 +---
  412 +
  413 +## 17. 前端对接提示(Group)
  414 +
  415 +- 「Search」→ `keyword`;按父级合作伙伴筛选 → `partnerId`(下拉选中项的 `id`);「Active」→ `state`。
  416 +- 「Assign to Partner」下拉数据来自 **Partner 列表接口**(`/api/app/partner`)。
  417 +- 「Bulk Export (PDF)」→ **第 16 节**,查询参数与当前列表筛选一致。
... ...
项目相关文档/报表Reports接口对接说明.md 0 → 100644
  1 +# 报表 Reports 接口对接说明(Print Log / Label Report)
  2 +
  3 +> 适用范围:美国版 Web「Reports」— **Print Log** 列表与导出、**Label Report** 统计与导出、**重打**
  4 +> 实现:`IReportsAppService` / `ReportsAppService`;重打复用 `IUsAppLabelingAppService.ReprintAsync`(已支持 admin 跳过创建人校验)
  5 +> 路由前缀:`/api/app`
  6 +
  7 +---
  8 +
  9 +## 0. 角色与数据范围(必读)
  10 +
  11 +- 判断依据:`CurrentUser.Roles` 中是否存在**忽略大小写**等于 **`admin`** 的角色码(与 JWT 中角色码一致,参见 `AuthSessionAppService` / `ReportsRoleHelper`)。
  12 +- **`admin`**:**不按** `CreatedBy` 过滤,可查看/统计全部 `fl_label_print_task`(仍受 Partner/Group/Location/日期/关键字筛选)。
  13 +- **非 `admin`**:所有列表与统计仅包含 **`CreatedBy == 当前用户 Id`** 的打印任务。
  14 +- **重打**:非 admin 仅能重打本人任务;**`admin` 可重打任意用户任务**,但仍须 `locationId` 与历史任务一致(与 App 重打规则一致)。
  15 +
  16 +---
  17 +
  18 +## 1. Partner / Group / Location 筛选说明
  19 +
  20 +- **`locationId`**:若传则只查该门店(`location.Id` 字符串)。
  21 +- **`partnerId`**(`fl_partner.Id`):按合作伙伴名称与 `location.Partner` **文本全等**(trim 后)匹配,得到门店集合再过滤任务。
  22 +- **`groupId`**(`fl_group.Id`):按组织的 `GroupName` + 父级 `PartnerName` 与门店的 `GroupName` + `Partner` **文本全等**匹配门店。
  23 +- 若 Partner/Group 在库中不存在,返回**空列表/空统计**(不报错)。
  24 +- 未传 Partner/Group/Location 时:**不按门店集合预过滤**(仅日期、关键字、用户范围生效)。
  25 +
  26 +---
  27 +
  28 +## 2. Print Log — 分页查询
  29 +
  30 +- **方法**:`GET`
  31 +- **路径**:`/api/app/reports/print-log-list`(以 Swagger 为准;ABP 约定多为 `get-print-log-list` 映射到该路径)
  32 +
  33 +### 2.1 查询参数(`ReportsPrintLogGetListInputVo`)
  34 +
  35 +| 参数 | 类型 | 说明 |
  36 +|------|------|------|
  37 +| `skipCount` | int | 页码,从 **1** 起 |
  38 +| `maxResultCount` | int | 每页条数 |
  39 +| `sorting` | string | 可选:`PrintedAt asc` / `PrintedAt desc`(默认倒序) |
  40 +| `partnerId` | string | 可选 |
  41 +| `groupId` | string | 可选 |
  42 +| `locationId` | string | 可选 |
  43 +| `startDate` | date | 可选;默认与结束日组成约 30 天窗口 |
  44 +| `endDate` | date | 可选;默认今天(含当日) |
  45 +| `keyword` | string | 可选;匹配产品名、标签分类名、产品分类名(模糊) |
  46 +
  47 +### 2.2 响应项(`ReportsPrintLogListItemDto`)
  48 +
  49 +含:`taskId`、`labelCode`、`productName`、`categoryName`、`templateText`、`printedAt`、`printedByName`、`locationText`、`locationId`(重打必填)、`expiryDateText`(从 `PrintInputJson` 中尝试解析 `expiryDate` / `expiry` / `expirationDate`)。
  50 +
  51 +---
  52 +
  53 +## 3. Print Log — 导出 PDF
  54 +
  55 +- **方法**:`GET`
  56 +- **路径**:`/api/app/reports/export-print-log-pdf`
  57 +- **查询参数**:与 **§2** 相同(分页字段忽略);最多 **5000** 条,超出返回业务错误。
  58 +
  59 +---
  60 +
  61 +## 4. Print Log — 重打(Reprint)
  62 +
  63 +- **方法**:`POST`
  64 +- **路径**:`/api/app/reports/reprint-print-log`(实现内转发至 `UsAppLabelingAppService.ReprintAsync`,以 Swagger 为准)
  65 +- **Body**:`UsAppLabelReprintInputVo`:`locationId`、`taskId`、`printQuantity`、`clientRequestId`(可选)、打印机字段(可选)。
  66 +
  67 +---
  68 +
  69 +## 5. Label Report — 统计聚合
  70 +
  71 +- **方法**:`GET`
  72 +- **路径**:`/api/app/reports/label-report`(以 Swagger 为准)
  73 +
  74 +### 5.1 查询参数(`ReportsLabelReportQueryInputVo`)
  75 +
  76 +与 Print Log 相同的 `partnerId`、`groupId`、`locationId`、`startDate`、`endDate`、`keyword`(无分页)。
  77 +
  78 +### 5.2 默认时间窗
  79 +
  80 +未传日期时:**结束日 = 今天,开始日 = 结束日前推 29 天(共约 30 个自然日,含首尾)**。
  81 +
  82 +### 5.3 返回(`ReportsLabelReportOutputDto`)
  83 +
  84 +- **`summary`**:`totalLabelsPrinted`、上一同长周期 `totalLabelsPrintedPrevPeriod`、`totalLabelsPrintedChangeRate`(%);最热门标签分类名与次数;Top 产品名与次数;`avgDailyPrints` 及环比等。
  85 +- **`labelsByCategory`**:按 **标签分类**(`fl_label_category`)汇总当前区间内打印次数。
  86 +- **`printVolumeTrend`**:在**当前筛选日期区间内**,取**结束日向前最多 7 个自然日**(与区间求交)的按日打印量。
  87 +- **`mostUsedProducts`**:当前区间内产品打印次数 Top20,`usagePercent` 为占**当期总打印次数**的百分比。
  88 +
  89 +---
  90 +
  91 +## 6. Label Report — 导出 PDF
  92 +
  93 +- **方法**:`GET`
  94 +- **路径**:`/api/app/reports/export-label-report-pdf`
  95 +- **查询参数**:与 **§5** 相同;内容为摘要 + 分类表 + 日趋势表 + Top 产品表。
  96 +
  97 +---
  98 +
  99 +## 7. 数据表与字段依赖
  100 +
  101 +- 打印任务:`fl_label_print_task`(`CreatedBy`、`LocationId`、`PrintedAt`、`PrintInputJson` 等)
  102 +- 门店:`location`(`Partner`、`GroupName`、`LocationCode`、`LocationName`)
  103 +- 主数据:`fl_partner`、`fl_group`(筛选用)
  104 +- 用户:`User`(展示 `PrintedByName`)
  105 +
  106 +---
  107 +
  108 +## 8. 前端对接提示
  109 +
  110 +- Print Log 行内 **Reprint**:使用列表返回的 `locationId` + `taskId` 调重打接口。
  111 +- 「Export Report」在 Print Log Tab 调 **§3**;在 Label Report Tab 调 **§6**。
  112 +- 下拉 **Partner / Group / Location** 与列表筛选字段一致;需保证门店维护的 `Partner` / `GroupName` 与主数据名称一致,否则筛选结果为空。
... ...
项目相关文档/美国版App登录接口说明.md
... ... @@ -149,6 +149,142 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
149 149  
150 150 ---
151 151  
  152 +## 接口 3:我的资料(My Profile)
  153 +
  154 +用于「My Profile」页展示:全名、邮箱、电话、员工号、角色。
  155 +
  156 +### HTTP
  157 +
  158 +- **方法**:`GET`
  159 +- **路径**:`/api/app/us-app-auth/my-profile`(以 Swagger 中 `UsAppAuth` 为准)
  160 +- **鉴权**:需要登录(`Authorization: Bearer {token}`)
  161 +
  162 +### 请求参数
  163 +
  164 +无。
  165 +
  166 +### 响应体(UsAppMyProfileOutputDto)
  167 +
  168 +| 字段(JSON) | 类型 | 说明 |
  169 +|--------------|------|------|
  170 +| `fullName` | string | `User.Name`,无则 `Nick`,再无则 `UserName` |
  171 +| `email` | string | `User.Email`;空为 `"无"` |
  172 +| `phone` | string | 由 `User.Phone`(long)格式化为可读字符串;10 位数字按 `+1 (555) 123-4567` 形式;无则 `"无"` |
  173 +| `employeeId` | string | 当前取 **`User.UserName`**(可与业务约定为工号/登录名) |
  174 +| `roleDisplay` | string | 多角色 `Role.RoleName` 按 `OrderNum` 排序后以英文逗号拼接;无角色为 `"无"` |
  175 +| `primaryRoleCode` | string \| null | 第一个角色的 `RoleCode`,供前端样式 |
  176 +
  177 +---
  178 +
  179 +## 接口 4:修改密码(Change Password)
  180 +
  181 +与 **`User.JudgePassword` / `BuildPassword`** 一致:校验当前密码后写入新盐值哈希。
  182 +
  183 +### HTTP
  184 +
  185 +- **方法**:`POST`
  186 +- **路径**:`/api/app/us-app-auth/change-password`
  187 +- **Content-Type**:`application/json`
  188 +- **鉴权**:需要登录
  189 +
  190 +### 请求体(UsAppChangePasswordInputVo)
  191 +
  192 +| 字段(JSON) | 类型 | 必填 | 说明 |
  193 +|--------------|------|------|------|
  194 +| `currentPassword` | string | 是 | 当前明文密码 |
  195 +| `newPassword` | string | 是 | 新密码 |
  196 +| `confirmNewPassword` | string | 是 | 必须与 `newPassword` **完全一致** |
  197 +
  198 +### 新密码复杂度(与原型「Password Requirements」一致)
  199 +
  200 +1. 至少 **8** 位
  201 +2. 至少 **1** 个大写字母、**1** 个小写字母
  202 +3. 至少 **1** 个数字
  203 +4. 至少 **1** 个**非字母数字**字符(特殊字符)
  204 +
  205 +### 请求示例
  206 +
  207 +```json
  208 +{
  209 + "currentPassword": "OldPass1!",
  210 + "newPassword": "NewPass9@x",
  211 + "confirmNewPassword": "NewPass9@x"
  212 +}
  213 +```
  214 +
  215 +### 响应
  216 +
  217 +成功时 HTTP 200,无业务体要求(ABP 可能返回空对象);失败为业务异常文案。
  218 +
  219 +### 常见错误
  220 +
  221 +- 未登录:`用户未登录`
  222 +- 当前密码错误:与登录失败一致(`UserConst.Login_Error` 文案)
  223 +- 新密码与确认不一致:`新密码与确认密码不一致`
  224 +- 新密码与当前相同:`新密码不能与当前密码相同`
  225 +- 不满足复杂度:如 `新密码至少 8 位`、`新密码需包含大写字母` 等
  226 +
  227 +---
  228 +
  229 +## 接口 5:门店详情(Location)
  230 +
  231 +用于移动端「Location」页:店名、完整地址、门店电话、营业时间占位、店长姓名与电话。**仅当当前 JWT 用户在 `userlocation` 中绑定该门店**时可查(与接口 1、2 的门店范围一致)。
  232 +
  233 +### HTTP
  234 +
  235 +- **方法**:`GET`
  236 +- **路径**:`/api/app/us-app-auth/location-detail`(以 Swagger 中 `UsAppAuth` → `GetLocationDetail` 为准;`locationId` 一般为 **Query** 参数)
  237 +- **鉴权**:需要登录(`Authorization: Bearer {token}`)
  238 +
  239 +### 请求参数
  240 +
  241 +| 参数名 | 位置 | 类型 | 必填 | 说明 |
  242 +|--------|------|------|------|------|
  243 +| `locationId` | Query | string | 是 | 门店主键,Guid 字符串(与 `locations[].id` 一致) |
  244 +
  245 +### 传参示例
  246 +
  247 +```http
  248 +GET /api/app/us-app-auth/location-detail?locationId=a2696b9e-2277-11f1-b4c6-00163e0c7c4f HTTP/1.1
  249 +Host: localhost:19001
  250 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
  251 +```
  252 +
  253 +### 响应体(UsAppLocationDetailOutputDto)
  254 +
  255 +| 字段(JSON) | 类型 | 说明 |
  256 +|--------------|------|------|
  257 +| `locationId` | string | 门店主键 |
  258 +| `locationName` | string | 门店名称;空为 `"无"` |
  259 +| `fullAddress` | string | 与登录接口相同的地址拼接规则;无有效片段为 `"无"` |
  260 +| `storePhone` | string | `location.Phone` 去首尾空格;空为 `"无"` |
  261 +| `operatingHours` | string | 当前 **`location` 表无营业时间字段**,固定返回 **`"无"`**(后续若落库可再对接) |
  262 +| `managerName` | string | 在同店 `userlocation` 绑定用户中,取角色 `RoleCode` / `RoleName`(忽略大小写)包含 **`manager`** 的第一人展示姓名(`Name` → `Nick` → `UserName`);无匹配为 **`"无"`** |
  263 +| `managerPhone` | string | 同上用户的 `User.Phone`(long)按与「我的资料」相同的可读格式;无电话为 **`"无"`** |
  264 +
  265 +### 响应示例
  266 +
  267 +```json
  268 +{
  269 + "locationId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",
  270 + "locationName": "Downtown Store",
  271 + "fullAddress": "123 Main St, New York, NY 10001",
  272 + "storePhone": "(212) 555-0100",
  273 + "operatingHours": "无",
  274 + "managerName": "Jane Doe",
  275 + "managerPhone": "+1 (555) 123-4567"
  276 +}
  277 +```
  278 +
  279 +### 常见错误(业务异常文案)
  280 +
  281 +- 未登录:`用户未登录`
  282 +- `locationId` 非法或空:`无效的门店标识`
  283 +- 当前用户未绑定该门店:`当前账号未绑定该门店,无法查看`
  284 +- 门店已删或不存在:`门店不存在或已删除`
  285 +
  286 +---
  287 +
152 288 ## 与其他登录方式的区别
153 289  
154 290 | 场景 | 说明 |
... ...