diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Dashboard/DashboardOverviewOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Dashboard/DashboardOverviewOutputDto.cs
index 03bc5fc..195477e 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Dashboard/DashboardOverviewOutputDto.cs
+++ b/美国版/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
/// 按分类分布总数(前端直观命名)
public int ByCategoryTotal { get; set; }
+ /// 最近打印标签(全门店最新若干条,用于 Recent Labels 区块)
+ public List RecentLabels { get; set; } = new();
+
/// 统计时间
public DateTime GeneratedAt { get; set; }
}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Dashboard/DashboardRecentLabelItemDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Dashboard/DashboardRecentLabelItemDto.cs
new file mode 100644
index 0000000..a3a6de6
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Dashboard/DashboardRecentLabelItemDto.cs
@@ -0,0 +1,31 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Dashboard;
+
+///
+/// Dashboard「Recent Labels」单行数据(最近打印记录)
+///
+public class DashboardRecentLabelItemDto
+{
+ /// 打印任务 Id(fl_label_print_task.Id)
+ public string TaskId { get; set; } = string.Empty;
+
+ /// 标签编码(界面 Serial / Label ID,如 1-251201)
+ public string LabelCode { get; set; } = string.Empty;
+
+ /// 展示名称:优先产品名,否则标签名称
+ public string DisplayName { get; set; } = "无";
+
+ /// 打印人用户 Id(CreatedBy)
+ public string? PrintedByUserId { get; set; }
+
+ /// 打印人展示名(User.Name 或 UserName)
+ public string PrintedByName { get; set; } = "无";
+
+ /// 打印时间(PrintedAt 优先,否则 CreationTime)
+ public DateTime PrintedAt { get; set; }
+
+ /// 状态:active 或 expired(依据 PrintInputJson 中保质期与当前日期比较)
+ public string Status { get; set; } = "active";
+
+ /// 角标/尺寸短文案(如 2"x2",用于左侧圆标)
+ public string LabelTypeBadge { get; set; } = "无";
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupCreateInputVo.cs
new file mode 100644
index 0000000..5b75864
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupCreateInputVo.cs
@@ -0,0 +1,16 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Group;
+
+///
+/// 新建组织入参
+///
+public class GroupCreateInputVo
+{
+ public string GroupName { get; set; } = string.Empty;
+
+ ///
+ /// 指派到的合作伙伴 Id(Assign to Partner)
+ ///
+ public string PartnerId { get; set; } = string.Empty;
+
+ public bool State { get; set; } = true;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetListInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetListInputVo.cs
new file mode 100644
index 0000000..6bfc39b
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetListInputVo.cs
@@ -0,0 +1,24 @@
+using Volo.Abp.Application.Dtos;
+
+namespace FoodLabeling.Application.Contracts.Dtos.Group;
+
+///
+/// 组织(Group)分页查询入参
+///
+public class GroupGetListInputVo : PagedAndSortedResultRequestDto
+{
+ ///
+ /// 模糊搜索(GroupName、所属 Partner 的 PartnerName)
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 按所属合作伙伴筛选(fl_partner.Id)
+ ///
+ public string? PartnerId { get; set; }
+
+ ///
+ /// 启用状态
+ ///
+ public bool? State { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetListOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetListOutputDto.cs
new file mode 100644
index 0000000..0971594
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetListOutputDto.cs
@@ -0,0 +1,22 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Group;
+
+///
+/// 组织列表项
+///
+public class GroupGetListOutputDto
+{
+ public string Id { get; set; } = string.Empty;
+
+ public string GroupName { get; set; } = string.Empty;
+
+ public string PartnerId { get; set; } = string.Empty;
+
+ ///
+ /// 所属合作伙伴名称(列表「Parent Partner」列)
+ ///
+ public string PartnerName { get; set; } = string.Empty;
+
+ public bool State { get; set; }
+
+ public DateTime CreationTime { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetOutputDto.cs
new file mode 100644
index 0000000..bceab5b
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupGetOutputDto.cs
@@ -0,0 +1,21 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Group;
+
+///
+/// 组织详情
+///
+public class GroupGetOutputDto
+{
+ public string Id { get; set; } = string.Empty;
+
+ public string GroupName { get; set; } = string.Empty;
+
+ public string PartnerId { get; set; } = string.Empty;
+
+ public string PartnerName { get; set; } = string.Empty;
+
+ public bool State { get; set; }
+
+ public DateTime CreationTime { get; set; }
+
+ public DateTime? LastModificationTime { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupUpdateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupUpdateInputVo.cs
new file mode 100644
index 0000000..563e060
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupUpdateInputVo.cs
@@ -0,0 +1,13 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Group;
+
+///
+/// 编辑组织入参
+///
+public class GroupUpdateInputVo
+{
+ public string GroupName { get; set; } = string.Empty;
+
+ public string PartnerId { get; set; } = string.Empty;
+
+ public bool State { get; set; } = true;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerCreateInputVo.cs
new file mode 100644
index 0000000..18d024c
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerCreateInputVo.cs
@@ -0,0 +1,15 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Partner;
+
+///
+/// 新建合作伙伴入参
+///
+public class PartnerCreateInputVo
+{
+ public string PartnerName { get; set; } = string.Empty;
+
+ public string? ContactEmail { get; set; }
+
+ public string? PhoneNumber { get; set; }
+
+ public bool State { get; set; } = true;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetListInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetListInputVo.cs
new file mode 100644
index 0000000..50a2387
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetListInputVo.cs
@@ -0,0 +1,19 @@
+using Volo.Abp.Application.Dtos;
+
+namespace FoodLabeling.Application.Contracts.Dtos.Partner;
+
+///
+/// 合作伙伴分页查询入参
+///
+public class PartnerGetListInputVo : PagedAndSortedResultRequestDto
+{
+ ///
+ /// 模糊搜索(PartnerName / ContactEmail / PhoneNumber)
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 启用状态(与列表筛选一致)
+ ///
+ public bool? State { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetListOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetListOutputDto.cs
new file mode 100644
index 0000000..42bef41
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetListOutputDto.cs
@@ -0,0 +1,19 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Partner;
+
+///
+/// 合作伙伴列表项
+///
+public class PartnerGetListOutputDto
+{
+ public string Id { get; set; } = string.Empty;
+
+ public string PartnerName { get; set; } = string.Empty;
+
+ public string? ContactEmail { get; set; }
+
+ public string? PhoneNumber { get; set; }
+
+ public bool State { get; set; }
+
+ public DateTime CreationTime { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetOutputDto.cs
new file mode 100644
index 0000000..f4bf1ac
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerGetOutputDto.cs
@@ -0,0 +1,21 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Partner;
+
+///
+/// 合作伙伴详情
+///
+public class PartnerGetOutputDto
+{
+ public string Id { get; set; } = string.Empty;
+
+ public string PartnerName { get; set; } = string.Empty;
+
+ public string? ContactEmail { get; set; }
+
+ public string? PhoneNumber { get; set; }
+
+ public bool State { get; set; }
+
+ public DateTime CreationTime { get; set; }
+
+ public DateTime? LastModificationTime { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerUpdateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerUpdateInputVo.cs
new file mode 100644
index 0000000..a24c330
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Partner/PartnerUpdateInputVo.cs
@@ -0,0 +1,15 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Partner;
+
+///
+/// 编辑合作伙伴入参
+///
+public class PartnerUpdateInputVo
+{
+ public string PartnerName { get; set; } = string.Empty;
+
+ public string? ContactEmail { get; set; }
+
+ public string? PhoneNumber { get; set; }
+
+ public bool State { get; set; } = true;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsLabelReportOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsLabelReportOutputDto.cs
new file mode 100644
index 0000000..aacfd87
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsLabelReportOutputDto.cs
@@ -0,0 +1,71 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Reports;
+
+///
+/// Label Report 聚合结果(卡片 + 图表 + Top 产品表)
+///
+public class ReportsLabelReportOutputDto
+{
+ public ReportsLabelReportSummaryDto Summary { get; set; } = new();
+
+ /// 按标签分类的打印量(柱状图)
+ public List LabelsByCategory { get; set; } = new();
+
+ /// 折线图:默认统计区间内最后 7 个自然日(按日汇总)
+ public List PrintVolumeTrend { get; set; } = new();
+
+ /// 用量最高的产品(含占比)
+ public List MostUsedProducts { get; set; } = new();
+}
+
+public class ReportsLabelReportSummaryDto
+{
+ public int TotalLabelsPrinted { get; set; }
+
+ public int TotalLabelsPrintedPrevPeriod { get; set; }
+
+ /// 相对上一同长周期变化率(百分比,如 20.1 表示 +20.1%)
+ public decimal TotalLabelsPrintedChangeRate { get; set; }
+
+ public string MostPrintedCategoryName { get; set; } = "无";
+
+ public int MostPrintedCategoryCount { get; set; }
+
+ public string TopProductName { get; set; } = "无";
+
+ public int TopProductCount { get; set; }
+
+ public decimal AvgDailyPrints { get; set; }
+
+ public decimal AvgDailyPrintsPrevPeriod { get; set; }
+
+ public decimal AvgDailyPrintsChangeRate { get; set; }
+}
+
+public class ReportsCategoryCountDto
+{
+ public string CategoryId { get; set; } = string.Empty;
+
+ public string CategoryName { get; set; } = "无";
+
+ public int Count { get; set; }
+}
+
+public class ReportsDailyCountDto
+{
+ public string Date { get; set; } = string.Empty;
+
+ public int Count { get; set; }
+}
+
+public class ReportsTopProductRowDto
+{
+ public string ProductId { get; set; } = string.Empty;
+
+ public string ProductName { get; set; } = "无";
+
+ public string CategoryName { get; set; } = "无";
+
+ public int TotalPrinted { get; set; }
+
+ public decimal UsagePercent { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsLabelReportQueryInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsLabelReportQueryInputVo.cs
new file mode 100644
index 0000000..05f14d7
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsLabelReportQueryInputVo.cs
@@ -0,0 +1,19 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Reports;
+
+///
+/// Label Report 统计与导出共用筛选
+///
+public class ReportsLabelReportQueryInputVo
+{
+ public string? PartnerId { get; set; }
+
+ public string? GroupId { get; set; }
+
+ public string? LocationId { get; set; }
+
+ public DateTime? StartDate { get; set; }
+
+ public DateTime? EndDate { get; set; }
+
+ public string? Keyword { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsPrintLogGetListInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsPrintLogGetListInputVo.cs
new file mode 100644
index 0000000..b1ca2fc
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsPrintLogGetListInputVo.cs
@@ -0,0 +1,21 @@
+using Volo.Abp.Application.Dtos;
+
+namespace FoodLabeling.Application.Contracts.Dtos.Reports;
+
+///
+/// Web Reports — Print Log 分页查询
+///
+public class ReportsPrintLogGetListInputVo : PagedAndSortedResultRequestDto
+{
+ public string? PartnerId { get; set; }
+
+ public string? GroupId { get; set; }
+
+ public string? LocationId { get; set; }
+
+ public DateTime? StartDate { get; set; }
+
+ public DateTime? EndDate { get; set; }
+
+ public string? Keyword { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsPrintLogListItemDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsPrintLogListItemDto.cs
new file mode 100644
index 0000000..e994ae3
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Reports/ReportsPrintLogListItemDto.cs
@@ -0,0 +1,35 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Reports;
+
+///
+/// Print Log 列表行(Reports 管理端)
+///
+public class ReportsPrintLogListItemDto
+{
+ /// 打印任务 Id(fl_label_print_task.Id),重打时使用
+ public string TaskId { get; set; } = string.Empty;
+
+ /// 标签编码(展示为 Label ID)
+ public string LabelCode { get; set; } = string.Empty;
+
+ public string ProductName { get; set; } = "无";
+
+ /// 分类展示名(优先产品分类,否则标签分类)
+ public string CategoryName { get; set; } = "无";
+
+ /// 模板展示(尺寸 + 模板名)
+ public string TemplateText { get; set; } = "无";
+
+ public DateTime PrintedAt { get; set; }
+
+ /// 打印人姓名
+ public string PrintedByName { get; set; } = "无";
+
+ /// 门店展示:名称 (编码)
+ public string LocationText { get; set; } = "无";
+
+ /// 门店 Id(重打校验用)
+ public string? LocationId { get; set; }
+
+ /// 从 PrintInputJson 尽力解析的保质期文本;无则「无」
+ public string ExpiryDateText { get; set; } = "无";
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppChangePasswordInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppChangePasswordInputVo.cs
new file mode 100644
index 0000000..d04fe9e
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppChangePasswordInputVo.cs
@@ -0,0 +1,16 @@
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
+
+///
+/// App 修改密码入参
+///
+public class UsAppChangePasswordInputVo
+{
+ /// 当前密码
+ public string CurrentPassword { get; set; } = string.Empty;
+
+ /// 新密码
+ public string NewPassword { get; set; } = string.Empty;
+
+ /// 确认新密码
+ public string ConfirmNewPassword { get; set; } = string.Empty;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLocationDetailOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLocationDetailOutputDto.cs
new file mode 100644
index 0000000..d684a8b
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLocationDetailOutputDto.cs
@@ -0,0 +1,28 @@
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
+
+///
+/// App「Location」门店详情(与原型字段对齐)
+///
+public class UsAppLocationDetailOutputDto
+{
+ /// 门店主键(Guid 字符串)
+ public string LocationId { get; set; } = string.Empty;
+
+ /// 门店名称
+ public string LocationName { get; set; } = string.Empty;
+
+ /// 完整地址(街道、城市、州、邮编拼接;无则为「无」)
+ public string FullAddress { get; set; } = string.Empty;
+
+ /// 门店电话(来自 location.Phone;空为「无」)
+ public string StorePhone { get; set; } = string.Empty;
+
+ /// 营业时间;当前库无字段,固定返回「无」直至业务落库
+ public string OperatingHours { get; set; } = string.Empty;
+
+ /// 店长姓名;优先取绑定本店且角色名/编码含 manager 的用户
+ public string ManagerName { get; set; } = string.Empty;
+
+ /// 店长电话;同上用户 User.Phone 格式化;无则为「无」
+ public string ManagerPhone { get; set; } = string.Empty;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppMyProfileOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppMyProfileOutputDto.cs
new file mode 100644
index 0000000..107c365
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppMyProfileOutputDto.cs
@@ -0,0 +1,25 @@
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
+
+///
+/// App「我的资料」展示数据(My Profile)
+///
+public class UsAppMyProfileOutputDto
+{
+ /// 全名(Name,无则 Nick,再无则 UserName)
+ public string FullName { get; set; } = string.Empty;
+
+ /// 邮箱
+ public string Email { get; set; } = "无";
+
+ /// 电话展示(如 +1 (555) 123-4567);无则「无」
+ public string Phone { get; set; } = "无";
+
+ /// 员工号/登录名(当前使用 User.UserName,可与业务约定为工号)
+ public string EmployeeId { get; set; } = string.Empty;
+
+ /// 角色展示名(多角色英文逗号拼接,按角色 OrderNum)
+ public string RoleDisplay { get; set; } = "无";
+
+ /// 主角色编码(第一个角色 RoleCode,供前端样式)
+ public string? PrimaryRoleCode { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IDashboardAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IDashboardAppService.cs
index f14fb75..4e825da 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IDashboardAppService.cs
+++ b/美国版/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;
public interface IDashboardAppService : IApplicationService
{
///
- /// 获取 Dashboard 总览统计(卡片 + 周趋势 + 分类分布)
+ /// 获取 Dashboard 总览统计(卡片 + 周趋势 + 分类分布 + Recent Labels 最近打印)
///
Task GetOverviewAsync();
}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IGroupAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IGroupAppService.cs
new file mode 100644
index 0000000..8bd2957
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IGroupAppService.cs
@@ -0,0 +1,44 @@
+using FoodLabeling.Application.Contracts.Dtos.Common;
+using FoodLabeling.Application.Contracts.Dtos.Group;
+using Microsoft.AspNetCore.Mvc;
+using Volo.Abp.Application.Dtos;
+using Volo.Abp.Application.Services;
+
+namespace FoodLabeling.Application.Contracts.IServices;
+
+///
+/// 组织(Group)管理接口(fl_group)
+///
+public interface IGroupAppService : IApplicationService
+{
+ ///
+ /// 组织分页列表(与导出使用相同筛选条件)
+ ///
+ /// 分页与筛选;SkipCount 为页码(从 1 起)
+ Task> GetListAsync(GroupGetListInputVo input);
+
+ ///
+ /// 组织详情
+ ///
+ Task GetAsync(string id);
+
+ ///
+ /// 新增组织
+ ///
+ Task CreateAsync(GroupCreateInputVo input);
+
+ ///
+ /// 编辑组织
+ ///
+ Task UpdateAsync(string id, GroupUpdateInputVo input);
+
+ ///
+ /// 删除组织(逻辑删除)
+ ///
+ Task DeleteAsync(string id);
+
+ ///
+ /// 按列表相同筛选条件导出组织为 PDF(上限 5000 条)
+ ///
+ Task ExportPdfAsync(GroupGetListInputVo input);
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IPartnerAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IPartnerAppService.cs
new file mode 100644
index 0000000..47ee7e1
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IPartnerAppService.cs
@@ -0,0 +1,87 @@
+using FoodLabeling.Application.Contracts.Dtos.Common;
+using FoodLabeling.Application.Contracts.Dtos.Partner;
+using Microsoft.AspNetCore.Mvc;
+using Volo.Abp.Application.Dtos;
+using Volo.Abp.Application.Services;
+
+namespace FoodLabeling.Application.Contracts.IServices;
+
+///
+/// 合作伙伴管理接口(fl_partner)
+///
+public interface IPartnerAppService : IApplicationService
+{
+ ///
+ /// 合作伙伴分页列表(与导出使用相同筛选条件)
+ ///
+ /// 分页与筛选;SkipCount 为页码(从 1 起)
+ /// 分页数据
+ /// 成功
+ /// 参数错误
+ /// 服务器错误
+ Task> GetListAsync(PartnerGetListInputVo input);
+
+ ///
+ /// 合作伙伴详情
+ ///
+ /// 主键 Id
+ /// 详情
+ /// 成功
+ /// Id 无效
+ /// 服务器错误
+ Task GetAsync(string id);
+
+ ///
+ /// 新增合作伙伴
+ ///
+ /// 名称、邮箱、电话、启用状态
+ /// 新建后的详情
+ ///
+ /// 示例请求:
+ /// ```json
+ /// {
+ /// "partnerName": "Global Foods Inc.",
+ /// "contactEmail": "admin@globalfoods.com",
+ /// "phoneNumber": "+1 (555) 100-2000",
+ /// "state": true
+ /// }
+ /// ```
+ ///
+ /// 成功
+ /// 校验失败
+ /// 服务器错误
+ Task CreateAsync(PartnerCreateInputVo input);
+
+ ///
+ /// 编辑合作伙伴
+ ///
+ /// 主键 Id
+ /// 名称、邮箱、电话、启用状态
+ /// 更新后的详情
+ /// 成功
+ /// 校验失败或记录不存在
+ /// 服务器错误
+ Task UpdateAsync(string id, PartnerUpdateInputVo input);
+
+ ///
+ /// 删除合作伙伴(逻辑删除)
+ ///
+ /// 主键 Id
+ /// 成功
+ /// Id 无效或记录不存在
+ /// 服务器错误
+ Task DeleteAsync(string id);
+
+ ///
+ /// 按当前列表筛选条件批量导出合作伙伴为 PDF(不分页,上限 5000 条)
+ ///
+ /// 与列表相同的 Keyword、State;分页字段忽略
+ /// PDF 文件流
+ ///
+ /// 筛选条件需与 一致,便于统计与导出数据对齐。
+ ///
+ /// 成功返回 application/pdf
+ /// 参数错误
+ /// 服务器错误
+ Task ExportPdfAsync(PartnerGetListInputVo input);
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IReportsAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IReportsAppService.cs
new file mode 100644
index 0000000..e8ced4a
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IReportsAppService.cs
@@ -0,0 +1,38 @@
+using FoodLabeling.Application.Contracts.Dtos.Common;
+using FoodLabeling.Application.Contracts.Dtos.Reports;
+using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+using Microsoft.AspNetCore.Mvc;
+using Volo.Abp.Application.Services;
+
+namespace FoodLabeling.Application.Contracts.IServices;
+
+///
+/// Reports(Print Log / Label Report)管理端接口
+///
+public interface IReportsAppService : IApplicationService
+{
+ ///
+ /// Print Log 分页列表;角色 admin 可查全部,否则仅当前用户打印记录。
+ ///
+ Task> GetPrintLogListAsync(ReportsPrintLogGetListInputVo input);
+
+ ///
+ /// Print Log 导出 PDF(筛选与列表一致,最多 5000 条)
+ ///
+ Task ExportPrintLogPdfAsync(ReportsPrintLogGetListInputVo input);
+
+ ///
+ /// 根据历史任务重打(与 App 入参一致);admin 可重打任意用户任务,否则仅本人任务。
+ ///
+ Task ReprintPrintLogAsync(UsAppLabelReprintInputVo input);
+
+ ///
+ /// Label Report 统计(卡片 + 分类柱数据 + 7 日趋势 + Top 产品);admin 统计全部,否则仅当前用户。
+ ///
+ Task GetLabelReportAsync(ReportsLabelReportQueryInputVo input);
+
+ ///
+ /// Label Report 导出 PDF
+ ///
+ Task ExportLabelReportPdfAsync(ReportsLabelReportQueryInputVo input);
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs
index 1b439e0..acfdbf1 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs
+++ b/美国版/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
/// 获取当前登录账号已绑定的门店(用于切换门店等场景)
///
Task> GetMyLocationsAsync();
+
+ ///
+ /// 获取当前登录用户资料(My Profile:姓名、邮箱、电话、员工号、角色)
+ ///
+ /// 资料 DTO
+ /// 成功
+ /// 未登录或用户不存在
+ /// 服务器错误
+ Task GetMyProfileAsync();
+
+ ///
+ /// 当前登录用户修改密码(校验原密码与复杂度规则)
+ ///
+ /// 当前密码、新密码、确认密码
+ ///
+ /// 新密码需满足:至少 8 位;含大写与小写字母;至少 1 位数字;至少 1 个非字母数字特殊字符。
+ ///
+ /// 成功
+ /// 参数或校验失败
+ /// 服务器错误
+ Task ChangePasswordAsync(UsAppChangePasswordInputVo input);
+
+ ///
+ /// 按门店 Id 查询 Location 详情(须为当前账号 userlocation 绑定门店)
+ ///
+ /// 门店 Guid 字符串
+ /// 店名、地址、电话、营业时间占位、店长信息
+ /// 成功
+ /// 参数非法、未绑定或无权限
+ /// 服务器错误
+ Task GetLocationDetailAsync(string locationId);
}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj
index 9c45fa3..44933dd 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj
@@ -3,6 +3,7 @@
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs
new file mode 100644
index 0000000..01a205a
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs
@@ -0,0 +1,31 @@
+using Volo.Abp.Users;
+
+namespace FoodLabeling.Application.Helpers;
+
+///
+/// Reports 模块角色判断(与 JWT / CurrentUser.Roles 中的角色码一致)
+///
+public static class ReportsRoleHelper
+{
+ ///
+ /// 是否为管理员:任一角色码等于 admin(忽略大小写)则视为可查看全部打印数据。
+ ///
+ public static bool IsAdminRole(ICurrentUser currentUser)
+ {
+ if (currentUser.Roles is null)
+ {
+ return false;
+ }
+
+ foreach (var r in currentUser.Roles)
+ {
+ if (!string.IsNullOrWhiteSpace(r) &&
+ string.Equals(r.Trim(), "admin", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs
index 287be59..94470a5 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DashboardAppService.cs
@@ -1,7 +1,10 @@
+using System.Globalization;
+using System.Text.Json;
using FoodLabeling.Application.Contracts.Dtos.Dashboard;
using FoodLabeling.Application.Contracts.IServices;
using FoodLabeling.Application.Services.DbModels;
using FoodLabeling.Domain.Entities;
+using SqlSugar;
using Volo.Abp.Application.Services;
using Yi.Framework.Rbac.Domain.Entities;
using Yi.Framework.SqlSugarCore.Abstractions;
@@ -127,6 +130,72 @@ public class DashboardAppService : ApplicationService, IDashboardAppService
.ThenBy(x => x.CategoryName)
.ToList();
+ const int recentLabelsTake = 10;
+ var recentRaw = await _dbContext.SqlSugarClient.Queryable()
+ .LeftJoin((t, l) => t.LabelId == l.Id)
+ .LeftJoin((t, l, p) => t.ProductId == p.Id)
+ .LeftJoin((t, l, p, tpl) => t.TemplateId == tpl.Id)
+ .OrderBy((t, l, p, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime), OrderByType.Desc)
+ .Take(recentLabelsTake)
+ .Select((t, l, p, tpl) => new
+ {
+ t.Id,
+ LabelCode = l.LabelCode,
+ LabelName = l.LabelName,
+ ProductName = p.ProductName,
+ tpl.Width,
+ tpl.Height,
+ tpl.Unit,
+ t.PrintInputJson,
+ PrintedAt = SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
+ t.CreatedBy
+ })
+ .ToListAsync();
+
+ var recentUserIds = recentRaw
+ .Select(x => x.CreatedBy)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x!.Trim())
+ .Distinct()
+ .Select(x => Guid.TryParse(x, out var g) ? g : (Guid?)null)
+ .Where(x => x.HasValue)
+ .Select(x => x!.Value)
+ .ToList();
+
+ var recentUserMap = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (recentUserIds.Count > 0)
+ {
+ var users = await _userRepository._DbQueryable
+ .Where(u => !u.IsDeleted && recentUserIds.Contains(u.Id))
+ .Select(u => new { u.Id, u.Name, u.UserName })
+ .ToListAsync();
+ foreach (var u in users)
+ {
+ var display = !string.IsNullOrWhiteSpace(u.Name) ? u.Name.Trim() : u.UserName.Trim();
+ recentUserMap[u.Id.ToString()] = string.IsNullOrWhiteSpace(display) ? "无" : display;
+ }
+ }
+
+ var recentLabels = recentRaw.Select(x =>
+ {
+ var displayName = !string.IsNullOrWhiteSpace(x.ProductName)
+ ? x.ProductName.Trim()
+ : (string.IsNullOrWhiteSpace(x.LabelName) ? "无" : x.LabelName.Trim());
+ var printedAt = x.PrintedAt ?? DateTime.MinValue;
+ var status = ResolveRecentLabelStatus(x.PrintInputJson);
+ return new DashboardRecentLabelItemDto
+ {
+ TaskId = x.Id,
+ LabelCode = string.IsNullOrWhiteSpace(x.LabelCode) ? "无" : x.LabelCode.Trim(),
+ DisplayName = displayName,
+ PrintedByUserId = x.CreatedBy?.Trim(),
+ PrintedByName = ResolveRecentUserName(recentUserMap, x.CreatedBy),
+ PrintedAt = printedAt,
+ Status = status,
+ LabelTypeBadge = FormatTemplateBadge(x.Width, x.Height, x.Unit)
+ };
+ }).ToList();
+
var labelsPrintedTodayCard = BuildMetricCard("labelsPrintedToday", "Labels Printed Today", printedToday, printedYesterday);
var activeTemplatesCard = BuildMetricCard("activeTemplates", "Active Templates", activeTemplates, activeTemplatesPrevWeek);
var activeUsersCard = BuildMetricCard("activeUsers", "Active Users", activeUsers, activeUsersPrevWeek);
@@ -156,12 +225,100 @@ public class DashboardAppService : ApplicationService, IDashboardAppService
CategoryDistributionTotal = categoryDistributionTotal,
ByCategory = categoryDistribution,
ByCategoryTotal = categoryDistributionTotal,
+ RecentLabels = recentLabels,
GeneratedAt = now
};
return output;
}
+ private static string ResolveRecentUserName(Dictionary map, string? createdBy)
+ {
+ if (string.IsNullOrWhiteSpace(createdBy))
+ {
+ return "无";
+ }
+
+ return map.TryGetValue(createdBy.Trim(), out var n) ? n : "无";
+ }
+
+ ///
+ /// 依据 PrintInputJson 中的保质期字段与「当前日期」比较得到 active/expired。
+ ///
+ private static string ResolveRecentLabelStatus(string? printInputJson)
+ {
+ if (!TryParseExpiryDate(printInputJson, out var expiryDate))
+ {
+ return "active";
+ }
+
+ var today = DateTime.Now.Date;
+ return expiryDate.Date < today ? "expired" : "active";
+ }
+
+ private static bool TryParseExpiryDate(string? printInputJson, out DateTime expiryDate)
+ {
+ expiryDate = default;
+ if (string.IsNullOrWhiteSpace(printInputJson))
+ {
+ return false;
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(printInputJson);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ return false;
+ }
+
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ var key = prop.Name.Trim();
+ if (!key.Equals("expiryDate", StringComparison.OrdinalIgnoreCase) &&
+ !key.Equals("expiry", StringComparison.OrdinalIgnoreCase) &&
+ !key.Equals("expirationDate", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var v = prop.Value;
+ if (v.ValueKind == JsonValueKind.String)
+ {
+ var s = v.GetString();
+ if (!string.IsNullOrWhiteSpace(s) &&
+ DateTime.TryParse(s, CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dt))
+ {
+ expiryDate = dt;
+ return true;
+ }
+ }
+ else if (v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var unix))
+ {
+ expiryDate = DateTimeOffset.FromUnixTimeSeconds(unix).LocalDateTime;
+ return true;
+ }
+ }
+ }
+ catch
+ {
+ return false;
+ }
+
+ return false;
+ }
+
+ private static string FormatTemplateBadge(decimal w, decimal h, string? unit)
+ {
+ var u = (unit ?? "inch").Trim().ToLowerInvariant();
+ var ws = w.ToString(CultureInfo.InvariantCulture);
+ var hs = h.ToString(CultureInfo.InvariantCulture);
+ return u is "inch" or "in"
+ ? $"{ws}\"x{hs}\""
+ : $"{ws}x{hs}{u}";
+ }
+
private static DashboardMetricCardDto BuildMetricCard(string key, string title, int value, int previousValue)
{
var changeValue = value - previousValue;
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlGroupDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlGroupDbEntity.cs
new file mode 100644
index 0000000..912d853
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlGroupDbEntity.cs
@@ -0,0 +1,38 @@
+using SqlSugar;
+
+namespace FoodLabeling.Application.Services.DbModels;
+
+///
+/// 组织/分组(Account Management / Group,表 fl_group)
+///
+[SugarTable("fl_group")]
+public class FlGroupDbEntity
+{
+ [SugarColumn(IsPrimaryKey = true)]
+ public string Id { get; set; } = string.Empty;
+
+ public bool IsDeleted { get; set; }
+
+ public DateTime CreationTime { get; set; }
+
+ public string? CreatorId { get; set; }
+
+ public string? LastModifierId { get; set; }
+
+ public DateTime? LastModificationTime { get; set; }
+
+ ///
+ /// 组织名称(Group Name)
+ ///
+ public string GroupName { get; set; } = string.Empty;
+
+ ///
+ /// 所属合作伙伴 Id(fl_partner.Id)
+ ///
+ public string PartnerId { get; set; } = string.Empty;
+
+ ///
+ /// 是否启用(对应 UI Active)
+ ///
+ public bool State { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlPartnerDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlPartnerDbEntity.cs
new file mode 100644
index 0000000..606d2c6
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlPartnerDbEntity.cs
@@ -0,0 +1,43 @@
+using SqlSugar;
+
+namespace FoodLabeling.Application.Services.DbModels;
+
+///
+/// 合作伙伴主数据(Account Management / Partner,表 fl_partner)
+///
+[SugarTable("fl_partner")]
+public class FlPartnerDbEntity
+{
+ [SugarColumn(IsPrimaryKey = true)]
+ public string Id { get; set; } = string.Empty;
+
+ public bool IsDeleted { get; set; }
+
+ public DateTime CreationTime { get; set; }
+
+ public string? CreatorId { get; set; }
+
+ public string? LastModifierId { get; set; }
+
+ public DateTime? LastModificationTime { get; set; }
+
+ ///
+ /// 合作伙伴名称(公司名)
+ ///
+ public string PartnerName { get; set; } = string.Empty;
+
+ ///
+ /// 联系邮箱
+ ///
+ public string? ContactEmail { get; set; }
+
+ ///
+ /// 电话
+ ///
+ public string? PhoneNumber { get; set; }
+
+ ///
+ /// 是否启用(对应 UI Active)
+ ///
+ public bool State { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs
new file mode 100644
index 0000000..36d7c75
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs
@@ -0,0 +1,356 @@
+using FoodLabeling.Application.Helpers;
+using FoodLabeling.Application.Contracts.Dtos.Common;
+using FoodLabeling.Application.Contracts.Dtos.Group;
+using FoodLabeling.Application.Contracts.IServices;
+using FoodLabeling.Application.Services.DbModels;
+using Microsoft.AspNetCore.Mvc;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+using SqlSugar;
+using Volo.Abp;
+using Volo.Abp.Application.Services;
+using Volo.Abp.Guids;
+using Volo.Abp.Uow;
+using Yi.Framework.SqlSugarCore.Abstractions;
+
+namespace FoodLabeling.Application.Services;
+
+///
+/// 组织(Group)管理(fl_group)
+///
+public class GroupAppService : ApplicationService, IGroupAppService
+{
+ private const int ExportPdfMaxRows = 5000;
+
+ private readonly ISqlSugarDbContext _dbContext;
+ private readonly IGuidGenerator _guidGenerator;
+
+ public GroupAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator)
+ {
+ _dbContext = dbContext;
+ _guidGenerator = guidGenerator;
+ }
+
+ ///
+ public async Task> GetListAsync(GroupGetListInputVo input)
+ {
+ RefAsync total = 0;
+ var query = BuildGroupJoinedQuery(input);
+ var projected = query.Select((g, p) => new GroupGetListOutputDto
+ {
+ Id = g.Id,
+ GroupName = g.GroupName,
+ PartnerId = g.PartnerId,
+ PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "无" : p.PartnerName.Trim(),
+ State = g.State,
+ CreationTime = g.CreationTime
+ });
+
+ var items = await projected.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
+ return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
+ }
+
+ ///
+ public async Task GetAsync(string id)
+ {
+ var groupId = id?.Trim();
+ if (string.IsNullOrWhiteSpace(groupId))
+ {
+ throw new UserFriendlyException("组织Id不能为空");
+ }
+
+ var entity = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
+ if (entity is null)
+ {
+ throw new UserFriendlyException("组织不存在");
+ }
+
+ var partnerName = await ResolvePartnerNameAsync(entity.PartnerId);
+ return MapDetail(entity, partnerName);
+ }
+
+ ///
+ [UnitOfWork]
+ public async Task CreateAsync(GroupCreateInputVo input)
+ {
+ var name = input.GroupName?.Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new UserFriendlyException("组织名称不能为空");
+ }
+
+ var partnerId = input.PartnerId?.Trim();
+ if (string.IsNullOrWhiteSpace(partnerId))
+ {
+ throw new UserFriendlyException("请选择所属合作伙伴");
+ }
+
+ await EnsurePartnerExistsAsync(partnerId);
+
+ var now = Clock.Now;
+ var entity = new FlGroupDbEntity
+ {
+ Id = _guidGenerator.Create().ToString(),
+ IsDeleted = false,
+ GroupName = name,
+ PartnerId = partnerId,
+ State = input.State,
+ CreationTime = now,
+ CreatorId = CurrentUser?.Id?.ToString(),
+ LastModificationTime = now,
+ LastModifierId = CurrentUser?.Id?.ToString()
+ };
+
+ await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
+ return await GetAsync(entity.Id);
+ }
+
+ ///
+ [UnitOfWork]
+ public async Task UpdateAsync(string id, GroupUpdateInputVo input)
+ {
+ var groupId = id?.Trim();
+ if (string.IsNullOrWhiteSpace(groupId))
+ {
+ throw new UserFriendlyException("组织Id不能为空");
+ }
+
+ var entity = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
+ if (entity is null)
+ {
+ throw new UserFriendlyException("组织不存在");
+ }
+
+ var name = input.GroupName?.Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new UserFriendlyException("组织名称不能为空");
+ }
+
+ var partnerId = input.PartnerId?.Trim();
+ if (string.IsNullOrWhiteSpace(partnerId))
+ {
+ throw new UserFriendlyException("请选择所属合作伙伴");
+ }
+
+ await EnsurePartnerExistsAsync(partnerId);
+
+ entity.GroupName = name;
+ entity.PartnerId = partnerId;
+ entity.State = input.State;
+ entity.LastModificationTime = Clock.Now;
+ entity.LastModifierId = CurrentUser?.Id?.ToString();
+
+ await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
+ return await GetAsync(groupId);
+ }
+
+ ///
+ [UnitOfWork]
+ public async Task DeleteAsync(string id)
+ {
+ var groupId = id?.Trim();
+ if (string.IsNullOrWhiteSpace(groupId))
+ {
+ throw new UserFriendlyException("组织Id不能为空");
+ }
+
+ var entity = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == groupId);
+ if (entity is null)
+ {
+ throw new UserFriendlyException("组织不存在");
+ }
+
+ entity.IsDeleted = true;
+ entity.LastModificationTime = Clock.Now;
+ entity.LastModifierId = CurrentUser?.Id?.ToString();
+ await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
+ }
+
+ ///
+ public async Task ExportPdfAsync(GroupGetListInputVo input)
+ {
+ QuestPDF.Settings.License = LicenseType.Community;
+
+ var exportBase = BuildGroupJoinedQuery(input);
+ var count = await exportBase.CountAsync();
+ if (count > ExportPdfMaxRows)
+ {
+ throw new UserFriendlyException($"导出数据超过上限 {ExportPdfMaxRows} 条,请缩小筛选范围");
+ }
+
+ var rows = await BuildGroupJoinedQuery(input)
+ .Select((g, p) => new GroupGetListOutputDto
+ {
+ Id = g.Id,
+ GroupName = g.GroupName,
+ PartnerId = g.PartnerId,
+ PartnerName = string.IsNullOrWhiteSpace(p.PartnerName) ? "无" : p.PartnerName.Trim(),
+ State = g.State,
+ CreationTime = g.CreationTime
+ })
+ .Take(ExportPdfMaxRows)
+ .ToListAsync();
+
+ var fileName = $"groups_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
+ var document = Document.Create(container =>
+ {
+ container.Page(page =>
+ {
+ page.Margin(28);
+ page.DefaultTextStyle(x => x.FontSize(10));
+ page.Header().Text("Groups").SemiBold().FontSize(18);
+ page.Content().PaddingTop(12).Table(table =>
+ {
+ table.ColumnsDefinition(c =>
+ {
+ c.RelativeColumn(2.2f);
+ c.RelativeColumn(2.4f);
+ c.RelativeColumn(1f);
+ c.RelativeColumn(1.6f);
+ });
+
+ static IContainer CellHeader(IContainer c) =>
+ c.Background(Colors.Grey.Lighten3).Padding(6).DefaultTextStyle(x => x.SemiBold());
+
+ table.Cell().Element(CellHeader).Text("Group Name");
+ table.Cell().Element(CellHeader).Text("Parent Partner");
+ table.Cell().Element(CellHeader).Text("Status");
+ table.Cell().Element(CellHeader).Text("Created");
+
+ foreach (var e in rows)
+ {
+ var status = e.State ? "active" : "inactive";
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
+ .Text(e.GroupName ?? string.Empty);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
+ .Text(string.IsNullOrWhiteSpace(e.PartnerName) ? "无" : e.PartnerName);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
+ .Text(e.CreationTime.ToString("yyyy-MM-dd HH:mm"));
+ }
+ });
+ });
+ });
+
+ var stream = new MemoryStream();
+ document.GeneratePdf(stream);
+ stream.Position = 0;
+ return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
+ }
+
+ private ISugarQueryable BuildGroupJoinedQuery(GroupGetListInputVo input)
+ {
+ var keyword = input.Keyword?.Trim();
+ var partnerId = input.PartnerId?.Trim();
+
+ var query = _dbContext.SqlSugarClient.Queryable()
+ .LeftJoin((g, p) => g.PartnerId == p.Id && !p.IsDeleted)
+ .Where((g, p) => !g.IsDeleted)
+ .WhereIF(input.State != null, (g, p) => g.State == input.State)
+ .WhereIF(!string.IsNullOrWhiteSpace(partnerId), (g, p) => g.PartnerId == partnerId)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword),
+ (g, p) => g.GroupName.Contains(keyword!) ||
+ (p.PartnerName != null && p.PartnerName.Contains(keyword!)));
+
+ if (!string.IsNullOrWhiteSpace(input.Sorting))
+ {
+ var sorting = input.Sorting.Trim();
+ if (sorting.Equals("GroupName desc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderByDescending((g, p) => g.GroupName);
+ }
+ else if (sorting.Equals("GroupName asc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderBy((g, p) => g.GroupName);
+ }
+ else if (sorting.Equals("CreationTime desc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderByDescending((g, p) => g.CreationTime);
+ }
+ else if (sorting.Equals("CreationTime asc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderBy((g, p) => g.CreationTime);
+ }
+ else if (sorting.Equals("State desc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderByDescending((g, p) => g.State);
+ }
+ else if (sorting.Equals("State asc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderBy((g, p) => g.State);
+ }
+ else if (sorting.Equals("PartnerName desc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderByDescending((g, p) => p.PartnerName);
+ }
+ else if (sorting.Equals("PartnerName asc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderBy((g, p) => p.PartnerName);
+ }
+ else
+ {
+ query = query.OrderByDescending((g, p) => g.CreationTime);
+ }
+ }
+ else
+ {
+ query = query.OrderByDescending((g, p) => g.CreationTime);
+ }
+
+ return query;
+ }
+
+ private async Task EnsurePartnerExistsAsync(string partnerId)
+ {
+ var ok = await _dbContext.SqlSugarClient.Queryable()
+ .AnyAsync(x => !x.IsDeleted && x.Id == partnerId);
+ if (!ok)
+ {
+ throw new UserFriendlyException("所选合作伙伴不存在或已删除");
+ }
+ }
+
+ private async Task ResolvePartnerNameAsync(string partnerId)
+ {
+ var p = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
+ if (p is null || string.IsNullOrWhiteSpace(p.PartnerName))
+ {
+ return "无";
+ }
+
+ return p.PartnerName.Trim();
+ }
+
+ private static GroupGetOutputDto MapDetail(FlGroupDbEntity x, string partnerName) => new()
+ {
+ Id = x.Id,
+ GroupName = x.GroupName,
+ PartnerId = x.PartnerId,
+ PartnerName = partnerName,
+ State = x.State,
+ CreationTime = x.CreationTime,
+ LastModificationTime = x.LastModificationTime
+ };
+
+ private static PagedResultWithPageDto BuildPagedResult(int skipCount, int maxResultCount, int total,
+ List items)
+ {
+ var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
+ var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
+ var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
+ return new PagedResultWithPageDto
+ {
+ PageIndex = pageIndex,
+ PageSize = pageSize,
+ TotalCount = total,
+ TotalPages = totalPages,
+ Items = items
+ };
+ }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs
new file mode 100644
index 0000000..321e705
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs
@@ -0,0 +1,326 @@
+using FoodLabeling.Application.Helpers;
+using FoodLabeling.Application.Contracts.Dtos.Common;
+using FoodLabeling.Application.Contracts.Dtos.Partner;
+using FoodLabeling.Application.Contracts.IServices;
+using FoodLabeling.Application.Services.DbModels;
+using Microsoft.AspNetCore.Mvc;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+using SqlSugar;
+using Volo.Abp;
+using Volo.Abp.Application.Services;
+using Volo.Abp.Guids;
+using Volo.Abp.Uow;
+using Yi.Framework.SqlSugarCore.Abstractions;
+
+namespace FoodLabeling.Application.Services;
+
+///
+/// 合作伙伴管理(fl_partner)
+///
+public class PartnerAppService : ApplicationService, IPartnerAppService
+{
+ private const int ExportPdfMaxRows = 5000;
+
+ private readonly ISqlSugarDbContext _dbContext;
+ private readonly IGuidGenerator _guidGenerator;
+
+ public PartnerAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator)
+ {
+ _dbContext = dbContext;
+ _guidGenerator = guidGenerator;
+ }
+
+ ///
+ public async Task> GetListAsync(PartnerGetListInputVo input)
+ {
+ RefAsync total = 0;
+ var query = BuildPartnerListQuery(input);
+
+ var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
+ var items = entities.Select(MapListItem).ToList();
+ return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
+ }
+
+ ///
+ public async Task GetAsync(string id)
+ {
+ var partnerId = id?.Trim();
+ if (string.IsNullOrWhiteSpace(partnerId))
+ {
+ throw new UserFriendlyException("合作伙伴Id不能为空");
+ }
+
+ var entity = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
+ if (entity is null)
+ {
+ throw new UserFriendlyException("合作伙伴不存在");
+ }
+
+ return MapDetail(entity);
+ }
+
+ ///
+ [UnitOfWork]
+ public async Task CreateAsync(PartnerCreateInputVo input)
+ {
+ var name = input.PartnerName?.Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new UserFriendlyException("合作伙伴名称不能为空");
+ }
+
+ var email = input.ContactEmail?.Trim();
+ if (!string.IsNullOrWhiteSpace(email) && !IsPlausibleEmail(email))
+ {
+ throw new UserFriendlyException("联系邮箱格式不正确");
+ }
+
+ var now = Clock.Now;
+ var entity = new FlPartnerDbEntity
+ {
+ Id = _guidGenerator.Create().ToString(),
+ IsDeleted = false,
+ PartnerName = name,
+ ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email,
+ PhoneNumber = string.IsNullOrWhiteSpace(input.PhoneNumber) ? null : input.PhoneNumber.Trim(),
+ State = input.State,
+ CreationTime = now,
+ CreatorId = CurrentUser?.Id?.ToString(),
+ LastModificationTime = now,
+ LastModifierId = CurrentUser?.Id?.ToString()
+ };
+
+ await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
+ return await GetAsync(entity.Id);
+ }
+
+ ///
+ [UnitOfWork]
+ public async Task UpdateAsync(string id, PartnerUpdateInputVo input)
+ {
+ var partnerId = id?.Trim();
+ if (string.IsNullOrWhiteSpace(partnerId))
+ {
+ throw new UserFriendlyException("合作伙伴Id不能为空");
+ }
+
+ var entity = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
+ if (entity is null)
+ {
+ throw new UserFriendlyException("合作伙伴不存在");
+ }
+
+ var name = input.PartnerName?.Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new UserFriendlyException("合作伙伴名称不能为空");
+ }
+
+ var email = input.ContactEmail?.Trim();
+ if (!string.IsNullOrWhiteSpace(email) && !IsPlausibleEmail(email))
+ {
+ throw new UserFriendlyException("联系邮箱格式不正确");
+ }
+
+ entity.PartnerName = name;
+ entity.ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email;
+ entity.PhoneNumber = string.IsNullOrWhiteSpace(input.PhoneNumber) ? null : input.PhoneNumber.Trim();
+ entity.State = input.State;
+ entity.LastModificationTime = Clock.Now;
+ entity.LastModifierId = CurrentUser?.Id?.ToString();
+
+ await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
+ return await GetAsync(partnerId);
+ }
+
+ ///
+ [UnitOfWork]
+ public async Task DeleteAsync(string id)
+ {
+ var partnerId = id?.Trim();
+ if (string.IsNullOrWhiteSpace(partnerId))
+ {
+ throw new UserFriendlyException("合作伙伴Id不能为空");
+ }
+
+ var entity = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == partnerId);
+ if (entity is null)
+ {
+ throw new UserFriendlyException("合作伙伴不存在");
+ }
+
+ entity.IsDeleted = true;
+ entity.LastModificationTime = Clock.Now;
+ entity.LastModifierId = CurrentUser?.Id?.ToString();
+ await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
+ }
+
+ ///
+ public async Task ExportPdfAsync(PartnerGetListInputVo input)
+ {
+ QuestPDF.Settings.License = LicenseType.Community;
+
+ var count = await BuildPartnerListQuery(input).CountAsync();
+ var query = BuildPartnerListQuery(input);
+ if (count > ExportPdfMaxRows)
+ {
+ throw new UserFriendlyException($"导出数据超过上限 {ExportPdfMaxRows} 条,请缩小筛选范围");
+ }
+
+ var rows = await query.Take(ExportPdfMaxRows).ToListAsync();
+ var fileName = $"partners_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
+
+ var document = Document.Create(container =>
+ {
+ container.Page(page =>
+ {
+ page.Margin(28);
+ page.DefaultTextStyle(x => x.FontSize(10));
+ page.Header().Text("Partners").SemiBold().FontSize(18);
+ page.Content().PaddingTop(12).Table(table =>
+ {
+ table.ColumnsDefinition(c =>
+ {
+ c.RelativeColumn(2.2f);
+ c.RelativeColumn(2.4f);
+ c.RelativeColumn(1.8f);
+ c.RelativeColumn(1f);
+ c.RelativeColumn(1.6f);
+ });
+
+ static IContainer CellHeader(IContainer c) =>
+ c.Background(Colors.Grey.Lighten3).Padding(6).DefaultTextStyle(x => x.SemiBold());
+
+ table.Cell().Element(CellHeader).Text("Partner");
+ table.Cell().Element(CellHeader).Text("Contact");
+ table.Cell().Element(CellHeader).Text("Phone");
+ table.Cell().Element(CellHeader).Text("Status");
+ table.Cell().Element(CellHeader).Text("Created");
+
+ foreach (var e in rows)
+ {
+ var status = e.State ? "active" : "inactive";
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
+ .Text(e.PartnerName ?? string.Empty);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
+ .Text(string.IsNullOrWhiteSpace(e.ContactEmail) ? "无" : e.ContactEmail!);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
+ .Text(string.IsNullOrWhiteSpace(e.PhoneNumber) ? "无" : e.PhoneNumber!);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
+ .Text(e.CreationTime.ToString("yyyy-MM-dd HH:mm"));
+ }
+ });
+ });
+ });
+
+ var stream = new MemoryStream();
+ document.GeneratePdf(stream);
+ stream.Position = 0;
+ return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
+ }
+
+ private ISugarQueryable BuildPartnerListQuery(PartnerGetListInputVo input)
+ {
+ var keyword = input.Keyword?.Trim();
+ var query = _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted)
+ .WhereIF(input.State != null, x => x.State == input.State)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword),
+ x => x.PartnerName.Contains(keyword!) ||
+ (x.ContactEmail != null && x.ContactEmail.Contains(keyword!)) ||
+ (x.PhoneNumber != null && x.PhoneNumber.Contains(keyword!)));
+
+ if (!string.IsNullOrWhiteSpace(input.Sorting))
+ {
+ var sorting = input.Sorting.Trim();
+ if (sorting.Equals("PartnerName desc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderByDescending(x => x.PartnerName);
+ }
+ else if (sorting.Equals("PartnerName asc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderBy(x => x.PartnerName);
+ }
+ else if (sorting.Equals("CreationTime desc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderByDescending(x => x.CreationTime);
+ }
+ else if (sorting.Equals("CreationTime asc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderBy(x => x.CreationTime);
+ }
+ else if (sorting.Equals("State desc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderByDescending(x => x.State);
+ }
+ else if (sorting.Equals("State asc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderBy(x => x.State);
+ }
+ else
+ {
+ query = query.OrderByDescending(x => x.CreationTime);
+ }
+ }
+ else
+ {
+ query = query.OrderByDescending(x => x.CreationTime);
+ }
+
+ return query;
+ }
+
+ private static PartnerGetListOutputDto MapListItem(FlPartnerDbEntity x) => new()
+ {
+ Id = x.Id,
+ PartnerName = x.PartnerName,
+ ContactEmail = x.ContactEmail,
+ PhoneNumber = x.PhoneNumber,
+ State = x.State,
+ CreationTime = x.CreationTime
+ };
+
+ private static PartnerGetOutputDto MapDetail(FlPartnerDbEntity x) => new()
+ {
+ Id = x.Id,
+ PartnerName = x.PartnerName,
+ ContactEmail = x.ContactEmail,
+ PhoneNumber = x.PhoneNumber,
+ State = x.State,
+ CreationTime = x.CreationTime,
+ LastModificationTime = x.LastModificationTime
+ };
+
+ private static bool IsPlausibleEmail(string email)
+ {
+ if (email.Length > 256)
+ {
+ return false;
+ }
+
+ var at = email.IndexOf('@');
+ return at > 0 && at < email.Length - 1 && email.IndexOf('@', at + 1) < 0;
+ }
+
+ private static PagedResultWithPageDto BuildPagedResult(int skipCount, int maxResultCount, int total,
+ List items)
+ {
+ var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
+ var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
+ var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
+ return new PagedResultWithPageDto
+ {
+ PageIndex = pageIndex,
+ PageSize = pageSize,
+ TotalCount = total,
+ TotalPages = totalPages,
+ Items = items
+ };
+ }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ReportsAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ReportsAppService.cs
new file mode 100644
index 0000000..1218b35
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ReportsAppService.cs
@@ -0,0 +1,777 @@
+using System.Globalization;
+using System.Text.Json;
+using FoodLabeling.Application.Helpers;
+using FoodLabeling.Application.Contracts.Dtos.Common;
+using FoodLabeling.Application.Contracts.Dtos.Reports;
+using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+using FoodLabeling.Application.Contracts.IServices;
+using FoodLabeling.Application.Services.DbModels;
+using FoodLabeling.Domain.Entities;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+using SqlSugar;
+using Volo.Abp;
+using Volo.Abp.Application.Services;
+using Yi.Framework.Rbac.Domain.Entities;
+using Yi.Framework.SqlSugarCore.Abstractions;
+
+namespace FoodLabeling.Application.Services;
+
+///
+/// Reports(Print Log / Label Report)
+///
+[Authorize]
+public class ReportsAppService : ApplicationService, IReportsAppService
+{
+ private const int ExportPdfMaxRows = 5000;
+
+ private readonly ISqlSugarDbContext _dbContext;
+ private readonly IUsAppLabelingAppService _usAppLabelingAppService;
+
+ public ReportsAppService(ISqlSugarDbContext dbContext, IUsAppLabelingAppService usAppLabelingAppService)
+ {
+ _dbContext = dbContext;
+ _usAppLabelingAppService = usAppLabelingAppService;
+ }
+
+ ///
+ public async Task> GetPrintLogListAsync(
+ ReportsPrintLogGetListInputVo input)
+ {
+ if (input is null)
+ {
+ throw new UserFriendlyException("入参不能为空");
+ }
+
+ if (!CurrentUser.Id.HasValue)
+ {
+ throw new UserFriendlyException("用户未登录");
+ }
+
+ var locationIds = await ResolveFilteredLocationIdsAsync(input.PartnerId, input.GroupId, input.LocationId);
+ if (locationIds is not null && locationIds.Count == 0)
+ {
+ return EmptyPrintLogPage(input);
+ }
+
+ var (rangeStart, rangeEndExcl) = ResolveDateRange(input.StartDate, input.EndDate);
+ var isAdmin = ReportsRoleHelper.IsAdminRole(CurrentUser);
+ var currentUserIdStr = CurrentUser.Id.Value.ToString();
+ var keyword = input.Keyword?.Trim();
+
+ RefAsync total = 0;
+
+ var query = BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
+ .LeftJoin((t, l, p, lc, pc, loc, tpl) => t.TemplateId == tpl.Id)
+ .Where((t, l, p, lc, pc, loc, tpl) =>
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= rangeStart &&
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < rangeEndExcl);
+
+ if (!string.IsNullOrWhiteSpace(input.Sorting) &&
+ input.Sorting.Trim().Equals("PrintedAt asc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderBy((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
+ OrderByType.Asc);
+ }
+ else
+ {
+ query = query.OrderBy((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
+ OrderByType.Desc);
+ }
+
+ var pageRows = await query
+ .Select((t, l, p, lc, pc, loc, tpl) => new
+ {
+ t.Id,
+ LabelCode = l.LabelCode,
+ ProductName = p.ProductName,
+ LabelCategoryName = lc.CategoryName,
+ ProductCategoryName = pc.CategoryName,
+ tpl.Width,
+ tpl.Height,
+ tpl.Unit,
+ tpl.TemplateName,
+ t.PrintInputJson,
+ PrintedAt = SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
+ t.CreatedBy,
+ t.LocationId,
+ LocName = loc.LocationName,
+ LocCode = loc.LocationCode
+ })
+ .ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
+
+ var userMap = await LoadUserNameMapAsync(pageRows.Select(x => x.CreatedBy).Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x!).Distinct().ToList());
+
+ var items = pageRows.Select(x =>
+ {
+ var cat = !string.IsNullOrWhiteSpace(x.ProductCategoryName)
+ ? x.ProductCategoryName!.Trim()
+ : (string.IsNullOrWhiteSpace(x.LabelCategoryName) ? "无" : x.LabelCategoryName.Trim());
+ var templateText = FormatTemplateDisplay(x.Width, x.Height, x.Unit, x.TemplateName);
+ var locText = FormatLocationText(x.LocName, x.LocCode);
+ var printedAt = x.PrintedAt ?? DateTime.MinValue;
+ return new ReportsPrintLogListItemDto
+ {
+ TaskId = x.Id,
+ LabelCode = string.IsNullOrWhiteSpace(x.LabelCode) ? "无" : x.LabelCode.Trim(),
+ ProductName = string.IsNullOrWhiteSpace(x.ProductName) ? "无" : x.ProductName.Trim(),
+ CategoryName = string.IsNullOrWhiteSpace(cat) ? "无" : cat,
+ TemplateText = string.IsNullOrWhiteSpace(templateText) ? "无" : templateText,
+ PrintedAt = printedAt,
+ PrintedByName = ResolveUserName(userMap, x.CreatedBy),
+ LocationText = locText,
+ LocationId = x.LocationId?.Trim(),
+ ExpiryDateText = TryExtractExpiryText(x.PrintInputJson)
+ };
+ }).ToList();
+
+ return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
+ }
+
+ ///
+ public async Task ExportPrintLogPdfAsync(ReportsPrintLogGetListInputVo input)
+ {
+ QuestPDF.Settings.License = LicenseType.Community;
+ if (input is null)
+ {
+ throw new UserFriendlyException("入参不能为空");
+ }
+
+ if (!CurrentUser.Id.HasValue)
+ {
+ throw new UserFriendlyException("用户未登录");
+ }
+
+ var locationIds = await ResolveFilteredLocationIdsAsync(input.PartnerId, input.GroupId, input.LocationId);
+ if (locationIds is not null && locationIds.Count == 0)
+ {
+ return BuildEmptyPdf("print-log-empty.pdf");
+ }
+
+ var (rangeStart, rangeEndExcl) = ResolveDateRange(input.StartDate, input.EndDate);
+ var isAdmin = ReportsRoleHelper.IsAdminRole(CurrentUser);
+ var currentUserIdStr = CurrentUser.Id.Value.ToString();
+ var keyword = input.Keyword?.Trim();
+
+ var query = BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
+ .LeftJoin((t, l, p, lc, pc, loc, tpl) => t.TemplateId == tpl.Id)
+ .Where((t, l, p, lc, pc, loc, tpl) =>
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= rangeStart &&
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < rangeEndExcl)
+ .OrderBy((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime), OrderByType.Desc);
+
+ var count = await query.CountAsync();
+ if (count > ExportPdfMaxRows)
+ {
+ throw new UserFriendlyException($"导出数据超过上限 {ExportPdfMaxRows} 条,请缩小筛选范围");
+ }
+
+ var rows = await query.Take(ExportPdfMaxRows)
+ .Select((t, l, p, lc, pc, loc, tpl) => new
+ {
+ t.Id,
+ LabelCode = l.LabelCode,
+ ProductName = p.ProductName,
+ LabelCategoryName = lc.CategoryName,
+ ProductCategoryName = pc.CategoryName,
+ tpl.Width,
+ tpl.Height,
+ tpl.Unit,
+ tpl.TemplateName,
+ t.PrintInputJson,
+ PrintedAt = SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
+ t.CreatedBy,
+ LocName = loc.LocationName,
+ LocCode = loc.LocationCode
+ })
+ .ToListAsync();
+
+ var userMap = await LoadUserNameMapAsync(rows.Select(x => x.CreatedBy).Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x!).Distinct().ToList());
+
+ var fileName = $"print-log_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
+ var document = Document.Create(container =>
+ {
+ container.Page(page =>
+ {
+ page.Margin(22);
+ page.DefaultTextStyle(x => x.FontSize(8.5f));
+ page.Header().Text("Print Log").SemiBold().FontSize(16);
+ page.Content().PaddingTop(10).Table(table =>
+ {
+ table.ColumnsDefinition(c =>
+ {
+ c.RelativeColumn(1.1f);
+ c.RelativeColumn(1.2f);
+ c.RelativeColumn(0.9f);
+ c.RelativeColumn(1.1f);
+ c.RelativeColumn(1f);
+ c.RelativeColumn(0.9f);
+ c.RelativeColumn(0.9f);
+ c.RelativeColumn(0.8f);
+ });
+ static IContainer H(IContainer x) =>
+ x.Background(Colors.Grey.Lighten3).Padding(4).DefaultTextStyle(s => s.SemiBold());
+ table.Cell().Element(H).Text("Label ID");
+ table.Cell().Element(H).Text("Product");
+ table.Cell().Element(H).Text("Category");
+ table.Cell().Element(H).Text("Template");
+ table.Cell().Element(H).Text("Printed At");
+ table.Cell().Element(H).Text("Printed By");
+ table.Cell().Element(H).Text("Location");
+ table.Cell().Element(H).Text("Expiry");
+
+ foreach (var x in rows)
+ {
+ var cat = !string.IsNullOrWhiteSpace(x.ProductCategoryName)
+ ? x.ProductCategoryName!.Trim()
+ : (string.IsNullOrWhiteSpace(x.LabelCategoryName) ? "无" : x.LabelCategoryName.Trim());
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(string.IsNullOrWhiteSpace(x.LabelCode) ? "无" : x.LabelCode.Trim());
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(string.IsNullOrWhiteSpace(x.ProductName) ? "无" : x.ProductName.Trim());
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(cat);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(FormatTemplateDisplay(x.Width, x.Height, x.Unit, x.TemplateName));
+ var printedAt = x.PrintedAt ?? DateTime.MinValue;
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(printedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture));
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(ResolveUserName(userMap, x.CreatedBy));
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(FormatLocationText(x.LocName, x.LocCode));
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(TryExtractExpiryText(x.PrintInputJson));
+ }
+ });
+ });
+ });
+
+ var ms = new MemoryStream();
+ document.GeneratePdf(ms);
+ ms.Position = 0;
+ return new FileStreamResult(ms, "application/pdf") { FileDownloadName = fileName };
+ }
+
+ ///
+ public Task ReprintPrintLogAsync(UsAppLabelReprintInputVo input) =>
+ _usAppLabelingAppService.ReprintAsync(input);
+
+ ///
+ public async Task GetLabelReportAsync(ReportsLabelReportQueryInputVo input)
+ {
+ if (input is null)
+ {
+ throw new UserFriendlyException("入参不能为空");
+ }
+
+ if (!CurrentUser.Id.HasValue)
+ {
+ throw new UserFriendlyException("用户未登录");
+ }
+
+ var locationIds = await ResolveFilteredLocationIdsAsync(input.PartnerId, input.GroupId, input.LocationId);
+ if (locationIds is not null && locationIds.Count == 0)
+ {
+ return new ReportsLabelReportOutputDto();
+ }
+
+ var (curStart, curEndExcl) = ResolveDateRange(input.StartDate, input.EndDate);
+ var span = curEndExcl - curStart;
+ if (span.TotalDays < 1)
+ {
+ span = TimeSpan.FromDays(1);
+ }
+
+ var prevEndExcl = curStart;
+ var prevStart = curStart - span;
+ var isAdmin = ReportsRoleHelper.IsAdminRole(CurrentUser);
+ var currentUserIdStr = CurrentUser.Id.Value.ToString();
+ var keyword = input.Keyword?.Trim();
+
+ var totalCur = await BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
+ .Where((t, l, p, lc, pc, loc) =>
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= curStart &&
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < curEndExcl)
+ .CountAsync();
+
+ var totalPrev = await BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
+ .Where((t, l, p, lc, pc, loc) =>
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= prevStart &&
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < prevEndExcl)
+ .CountAsync();
+
+ var dayCount = Math.Max(1, (int)Math.Ceiling((curEndExcl - curStart).TotalDays));
+ var prevDayCount = Math.Max(1, (int)Math.Ceiling((prevEndExcl - prevStart).TotalDays));
+ var avgDaily = Math.Round((decimal)totalCur / dayCount, 2);
+ var avgDailyPrev = Math.Round((decimal)totalPrev / prevDayCount, 2);
+
+ var categoryRows = await BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
+ .Where((t, l, p, lc, pc, loc) =>
+ l.LabelCategoryId != null &&
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= curStart &&
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < curEndExcl)
+ .GroupBy((t, l, p, lc, pc, loc) => new { lc.Id, lc.CategoryName })
+ .Select((t, l, p, lc, pc, loc) => new { lc.Id, lc.CategoryName, Cnt = SqlFunc.AggregateCount(t.Id) })
+ .ToListAsync();
+
+ var topCat = categoryRows.OrderByDescending(x => x.Cnt).FirstOrDefault();
+
+ var productRows = await BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
+ .Where((t, l, p, lc, pc, loc) =>
+ !string.IsNullOrEmpty(p.Id) &&
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= curStart &&
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < curEndExcl)
+ .GroupBy((t, l, p, lc, pc, loc) => new { p.Id, p.ProductName, Cat = pc.CategoryName })
+ .Select((t, l, p, lc, pc, loc) => new { p.Id, p.ProductName, CategoryName = pc.CategoryName, Cnt = SqlFunc.AggregateCount(t.Id) })
+ .ToListAsync();
+
+ var topProd = productRows.OrderByDescending(x => x.Cnt).FirstOrDefault();
+ var topList = productRows.OrderByDescending(x => x.Cnt).Take(20).ToList();
+
+ var trendEndDay = curEndExcl.Date.AddDays(-1);
+ var trendStartDay = trendEndDay.AddDays(-6);
+ if (trendStartDay < curStart.Date)
+ {
+ trendStartDay = curStart.Date;
+ }
+
+ var trendEndExcl = trendEndDay.AddDays(1);
+
+ var trendRaw = await BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
+ .Where((t, l, p, lc, pc, loc) =>
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= trendStartDay &&
+ SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < trendEndExcl)
+ .Select((t, l, p, lc, pc, loc) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime))
+ .ToListAsync();
+
+ var trendDict = trendRaw
+ .Where(x => x.HasValue)
+ .GroupBy(x => x!.Value.Date)
+ .ToDictionary(g => g.Key, g => g.Count());
+
+ var trend = new List();
+ for (var d = trendStartDay; d <= trendEndDay; d = d.AddDays(1))
+ {
+ trend.Add(new ReportsDailyCountDto
+ {
+ Date = d.ToString("yyyy-MM-dd"),
+ Count = trendDict.TryGetValue(d, out var c) ? c : 0
+ });
+ }
+
+ var byCategory = categoryRows
+ .OrderByDescending(x => x.Cnt)
+ .Select(x => new ReportsCategoryCountDto
+ {
+ CategoryId = x.Id ?? string.Empty,
+ CategoryName = string.IsNullOrWhiteSpace(x.CategoryName) ? "无" : x.CategoryName.Trim(),
+ Count = x.Cnt
+ })
+ .ToList();
+
+ var mostUsed = topList.Select(x =>
+ {
+ var pct = totalCur <= 0 ? 0m : Math.Round(x.Cnt * 100m / totalCur, 2);
+ return new ReportsTopProductRowDto
+ {
+ ProductId = x.Id ?? string.Empty,
+ ProductName = string.IsNullOrWhiteSpace(x.ProductName) ? "无" : x.ProductName.Trim(),
+ CategoryName = string.IsNullOrWhiteSpace(x.CategoryName) ? "无" : x.CategoryName!.Trim(),
+ TotalPrinted = x.Cnt,
+ UsagePercent = pct
+ };
+ }).ToList();
+
+ return new ReportsLabelReportOutputDto
+ {
+ Summary = new ReportsLabelReportSummaryDto
+ {
+ TotalLabelsPrinted = totalCur,
+ TotalLabelsPrintedPrevPeriod = totalPrev,
+ TotalLabelsPrintedChangeRate = CalcChangeRate(totalCur, totalPrev),
+ MostPrintedCategoryName = topCat is null ? "无" : (topCat.CategoryName?.Trim() ?? "无"),
+ MostPrintedCategoryCount = topCat?.Cnt ?? 0,
+ TopProductName = topProd is null ? "无" : (topProd.ProductName?.Trim() ?? "无"),
+ TopProductCount = topProd?.Cnt ?? 0,
+ AvgDailyPrints = avgDaily,
+ AvgDailyPrintsPrevPeriod = avgDailyPrev,
+ AvgDailyPrintsChangeRate = CalcChangeRate(avgDaily, avgDailyPrev)
+ },
+ LabelsByCategory = byCategory,
+ PrintVolumeTrend = trend,
+ MostUsedProducts = mostUsed
+ };
+ }
+
+ ///
+ public async Task ExportLabelReportPdfAsync(ReportsLabelReportQueryInputVo input)
+ {
+ QuestPDF.Settings.License = LicenseType.Community;
+ var data = await GetLabelReportAsync(input);
+ var fileName = $"label-report_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
+ var document = Document.Create(container =>
+ {
+ container.Page(page =>
+ {
+ page.Margin(24);
+ page.DefaultTextStyle(x => x.FontSize(9));
+ page.Header().Text("Label Report").SemiBold().FontSize(16);
+ page.Content().Column(col =>
+ {
+ col.Spacing(10);
+ col.Item().Text(
+ $"Total printed: {data.Summary.TotalLabelsPrinted} (prev: {data.Summary.TotalLabelsPrintedPrevPeriod}, Δ%: {data.Summary.TotalLabelsPrintedChangeRate:0.##}%)");
+ col.Item().Text(
+ $"Top category: {data.Summary.MostPrintedCategoryName} ({data.Summary.MostPrintedCategoryCount})");
+ col.Item().Text($"Top product: {data.Summary.TopProductName} ({data.Summary.TopProductCount})");
+ col.Item().Text(
+ $"Avg daily: {data.Summary.AvgDailyPrints:0.##} (prev: {data.Summary.AvgDailyPrintsPrevPeriod:0.##}, Δ%: {data.Summary.AvgDailyPrintsChangeRate:0.##}%)");
+ col.Item().Text("By category:").SemiBold();
+ col.Item().Table(t =>
+ {
+ t.ColumnsDefinition(c => { c.RelativeColumn(2); c.RelativeColumn(1); });
+ t.Cell().Element(HeaderCell).Text("Category");
+ t.Cell().Element(HeaderCell).Text("Count");
+ foreach (var r in data.LabelsByCategory)
+ {
+ t.Cell().BorderBottom(0.5f).Padding(3).Text(r.CategoryName);
+ t.Cell().BorderBottom(0.5f).Padding(3).Text(r.Count.ToString());
+ }
+ });
+ col.Item().Text("Daily trend:").SemiBold();
+ col.Item().Table(t =>
+ {
+ t.ColumnsDefinition(c => { c.RelativeColumn(1.2f); c.RelativeColumn(1); });
+ t.Cell().Element(HeaderCell).Text("Date");
+ t.Cell().Element(HeaderCell).Text("Count");
+ foreach (var r in data.PrintVolumeTrend)
+ {
+ t.Cell().BorderBottom(0.5f).Padding(3).Text(r.Date);
+ t.Cell().BorderBottom(0.5f).Padding(3).Text(r.Count.ToString());
+ }
+ });
+ col.Item().Text("Most used products:").SemiBold();
+ col.Item().Table(t =>
+ {
+ t.ColumnsDefinition(c =>
+ {
+ c.RelativeColumn(1.5f);
+ c.RelativeColumn(1f);
+ c.RelativeColumn(0.8f);
+ c.RelativeColumn(0.7f);
+ });
+ t.Cell().Element(HeaderCell).Text("Product");
+ t.Cell().Element(HeaderCell).Text("Category");
+ t.Cell().Element(HeaderCell).Text("Total");
+ t.Cell().Element(HeaderCell).Text("%");
+ foreach (var r in data.MostUsedProducts)
+ {
+ t.Cell().BorderBottom(0.5f).Padding(3).Text(r.ProductName);
+ t.Cell().BorderBottom(0.5f).Padding(3).Text(r.CategoryName);
+ t.Cell().BorderBottom(0.5f).Padding(3).Text(r.TotalPrinted.ToString());
+ t.Cell().BorderBottom(0.5f).Padding(3).Text(r.UsagePercent.ToString("0.##"));
+ }
+ });
+ });
+ });
+ });
+
+ static IContainer HeaderCell(IContainer x) =>
+ x.Background(Colors.Grey.Lighten3).Padding(4).DefaultTextStyle(s => s.SemiBold());
+
+ var ms = new MemoryStream();
+ document.GeneratePdf(ms);
+ ms.Position = 0;
+ return new FileStreamResult(ms, "application/pdf") { FileDownloadName = fileName };
+ }
+
+ private ISugarQueryable
+ BuildReportTaskCore(
+ List? locationIds,
+ bool isAdmin,
+ string currentUserIdStr,
+ string? keyword)
+ {
+ return _dbContext.SqlSugarClient.Queryable()
+ .LeftJoin((t, l) => t.LabelId == l.Id)
+ .LeftJoin((t, l, p) => t.ProductId == p.Id)
+ .LeftJoin((t, l, p, lc) => l.LabelCategoryId == lc.Id)
+ .LeftJoin((t, l, p, lc, pc) => p.CategoryId == pc.Id)
+ .LeftJoin((t, l, p, lc, pc, loc) =>
+ t.LocationId != null && SqlFunc.ToString(loc.Id) == t.LocationId)
+ .Where((t, l, p, lc, pc, loc) => !loc.IsDeleted)
+ .WhereIF(!isAdmin, (t, l, p, lc, pc, loc) => t.CreatedBy == currentUserIdStr)
+ .WhereIF(locationIds is not null, (t, l, p, lc, pc, loc) => locationIds!.Contains(t.LocationId!))
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword),
+ (t, l, p, lc, pc, loc) =>
+ (p.ProductName != null && p.ProductName.Contains(keyword!)) ||
+ (lc.CategoryName != null && lc.CategoryName.Contains(keyword!)) ||
+ (pc.CategoryName != null && pc.CategoryName.Contains(keyword!)));
+ }
+
+ private static decimal CalcChangeRate(decimal current, decimal previous)
+ {
+ if (previous == 0)
+ {
+ return current > 0 ? 100m : 0m;
+ }
+
+ return Math.Round((current - previous) * 100m / previous, 2);
+ }
+
+ private static decimal CalcChangeRate(int current, int previous) =>
+ CalcChangeRate((decimal)current, (decimal)previous);
+
+ private async Task?> ResolveFilteredLocationIdsAsync(string? partnerId, string? groupId,
+ string? locationId)
+ {
+ var locId = locationId?.Trim();
+ if (!string.IsNullOrWhiteSpace(locId))
+ {
+ return new List { locId };
+ }
+
+ var gid = groupId?.Trim();
+ var pid = partnerId?.Trim();
+
+ if (string.IsNullOrWhiteSpace(pid) && string.IsNullOrWhiteSpace(gid))
+ {
+ return null;
+ }
+
+ var q = _dbContext.SqlSugarClient.Queryable().Where(x => !x.IsDeleted);
+
+ if (!string.IsNullOrWhiteSpace(gid))
+ {
+ var g = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == gid);
+ if (g is null)
+ {
+ return new List();
+ }
+
+ var gName = g.GroupName?.Trim() ?? string.Empty;
+ var partner = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == g.PartnerId);
+ var pName = partner?.PartnerName?.Trim() ?? string.Empty;
+ q = q.Where(x => x.GroupName == gName && x.Partner == pName);
+ }
+ else if (!string.IsNullOrWhiteSpace(pid))
+ {
+ var partner = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == pid);
+ if (partner is null)
+ {
+ return new List();
+ }
+
+ var pName = partner.PartnerName?.Trim() ?? string.Empty;
+ q = q.Where(x => x.Partner == pName);
+ }
+
+ var ids = await q.Select(x => SqlFunc.ToString(x.Id)).ToListAsync();
+ return ids;
+ }
+
+ private static (DateTime rangeStart, DateTime rangeEndExcl) ResolveDateRange(DateTime? startDate,
+ DateTime? endDate)
+ {
+ var endDay = (endDate ?? DateTime.Today).Date;
+ var endExcl = endDay.AddDays(1);
+ var start = (startDate ?? endDay.AddDays(-29)).Date;
+ if (start >= endExcl)
+ {
+ start = endExcl.AddDays(-1);
+ }
+
+ return (start, endExcl);
+ }
+
+ private static PagedResultWithPageDto EmptyPrintLogPage(
+ ReportsPrintLogGetListInputVo input)
+ {
+ var pageSize = input.MaxResultCount <= 0 ? 0 : input.MaxResultCount;
+ var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
+ return new PagedResultWithPageDto
+ {
+ PageIndex = pageIndex,
+ PageSize = pageSize,
+ TotalCount = 0,
+ TotalPages = 0,
+ Items = new List()
+ };
+ }
+
+ private static PagedResultWithPageDto BuildPagedResult(int skipCount, int maxResultCount, int total,
+ List items)
+ {
+ var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
+ var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
+ var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
+ return new PagedResultWithPageDto
+ {
+ PageIndex = pageIndex,
+ PageSize = pageSize,
+ TotalCount = total,
+ TotalPages = totalPages,
+ Items = items
+ };
+ }
+
+ private async Task> LoadUserNameMapAsync(List userIdStrings)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (userIdStrings.Count == 0)
+ {
+ return map;
+ }
+
+ var guids = userIdStrings
+ .Select(x => Guid.TryParse(x, out var g) ? g : (Guid?)null)
+ .Where(x => x.HasValue)
+ .Select(x => x!.Value)
+ .Distinct()
+ .ToList();
+ if (guids.Count == 0)
+ {
+ return map;
+ }
+
+ var users = await _dbContext.SqlSugarClient.Queryable()
+ .Where(u => !u.IsDeleted && guids.Contains(u.Id))
+ .Select(u => new { u.Id, u.Name, u.UserName })
+ .ToListAsync();
+
+ foreach (var u in users)
+ {
+ var display = !string.IsNullOrWhiteSpace(u.Name) ? u.Name.Trim() : u.UserName.Trim();
+ map[u.Id.ToString()] = string.IsNullOrWhiteSpace(display) ? "无" : display;
+ }
+
+ return map;
+ }
+
+ private static string ResolveUserName(Dictionary map, string? createdBy)
+ {
+ if (string.IsNullOrWhiteSpace(createdBy))
+ {
+ return "无";
+ }
+
+ return map.TryGetValue(createdBy.Trim(), out var n) ? n : "无";
+ }
+
+ private static string FormatLocationText(string? locName, string? locCode)
+ {
+ var n = locName?.Trim();
+ var c = locCode?.Trim();
+ if (string.IsNullOrWhiteSpace(n) && string.IsNullOrWhiteSpace(c))
+ {
+ return "无";
+ }
+
+ if (string.IsNullOrWhiteSpace(c))
+ {
+ return n ?? "无";
+ }
+
+ if (string.IsNullOrWhiteSpace(n))
+ {
+ return $"({c})";
+ }
+
+ return $"{n} ({c})";
+ }
+
+ private static string FormatTemplateDisplay(decimal w, decimal h, string? unit, string? templateName)
+ {
+ var size = FormatLabelSizeWithUnit(w, h, unit ?? "inch");
+ var tn = templateName?.Trim();
+ if (string.IsNullOrWhiteSpace(tn))
+ {
+ return size ?? "无";
+ }
+
+ return string.IsNullOrWhiteSpace(size) ? tn : $"{size} {tn}";
+ }
+
+ private static string? FormatLabelSizeWithUnit(decimal w, decimal h, string unit)
+ {
+ var u = (unit ?? "inch").Trim().ToLowerInvariant();
+ var ws = w.ToString(CultureInfo.InvariantCulture);
+ var hs = h.ToString(CultureInfo.InvariantCulture);
+ var normalizedUnit = u is "in" ? "inch" : u;
+ return $"{ws}x{hs}{normalizedUnit}";
+ }
+
+ private static string TryExtractExpiryText(string? printInputJson)
+ {
+ if (string.IsNullOrWhiteSpace(printInputJson))
+ {
+ return "无";
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(printInputJson);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ return "无";
+ }
+
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ var key = prop.Name.Trim();
+ if (!key.Equals("expiryDate", StringComparison.OrdinalIgnoreCase) &&
+ !key.Equals("expiry", StringComparison.OrdinalIgnoreCase) &&
+ !key.Equals("expirationDate", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var v = prop.Value;
+ if (v.ValueKind == JsonValueKind.String)
+ {
+ var s = v.GetString();
+ return string.IsNullOrWhiteSpace(s) ? "无" : s.Trim();
+ }
+
+ if (v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n))
+ {
+ return n.ToString(CultureInfo.InvariantCulture);
+ }
+
+ return v.ToString();
+ }
+ }
+ catch
+ {
+ return "无";
+ }
+
+ return "无";
+ }
+
+ private static IActionResult BuildEmptyPdf(string fileName)
+ {
+ QuestPDF.Settings.License = LicenseType.Community;
+ var document = Document.Create(c =>
+ {
+ c.Page(p =>
+ {
+ p.Margin(30);
+ p.Content().Text("No data for current filters.");
+ });
+ });
+ var ms = new MemoryStream();
+ document.GeneratePdf(ms);
+ ms.Position = 0;
+ return new FileStreamResult(ms, "application/pdf") { FileDownloadName = fileName };
+ }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs
index 93cd84c..44ebf48 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
@@ -20,6 +21,7 @@ using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.EventBus.Local;
using Volo.Abp.Security.Claims;
+using Volo.Abp.Uow;
using Volo.Abp.Users;
using Yi.Framework.Core.Helper;
using Yi.Framework.Rbac.Domain.Entities;
@@ -135,6 +137,267 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
return await LoadBoundLocationsAsync(CurrentUser.Id.Value);
}
+ ///
+ /// 查询单个门店详情(Location 页):地址、门店电话、营业时间占位、店长(角色含 manager 的绑定用户)
+ ///
+ ///
+ /// 仅当当前登录用户在 userlocation 中绑定该 locationId 时可查;否则返回业务异常。
+ ///
+ /// 店长:在同店绑定用户中,取 Role.RoleCode 或 Role.RoleName(忽略大小写)包含 manager 的第一条;
+ /// 若无匹配则店长姓名与电话均为「无」。
+ ///
+ /// OperatingHours:当前 location 表无营业时间字段,固定返回「无」。
+ ///
+ /// 门店主键(Guid 字符串)
+ /// 与原型一致的展示字段
+ /// 成功
+ /// 未登录、门店标识无效、未绑定或门店不存在
+ /// 服务器错误
+ [Authorize]
+ public virtual async Task GetLocationDetailAsync(string locationId)
+ {
+ if (!CurrentUser.Id.HasValue)
+ {
+ throw new UserFriendlyException("用户未登录");
+ }
+
+ var lid = (locationId ?? string.Empty).Trim();
+ if (string.IsNullOrEmpty(lid) || !Guid.TryParse(lid, out var locationGuid))
+ {
+ throw new UserFriendlyException("无效的门店标识");
+ }
+
+ var userIdStr = CurrentUser.Id.Value.ToString();
+ var bound = await _dbContext.SqlSugarClient.Queryable()
+ .AnyAsync(x => !x.IsDeleted && x.UserId == userIdStr && x.LocationId == lid);
+ if (!bound)
+ {
+ throw new UserFriendlyException("当前账号未绑定该门店,无法查看");
+ }
+
+ var locRows = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted && x.Id == locationGuid)
+ .ToListAsync();
+ var loc = locRows.FirstOrDefault();
+ if (loc is null)
+ {
+ throw new UserFriendlyException("门店不存在或已删除");
+ }
+
+ var (mgrName, mgrPhone) = await TryResolveStoreManagerAsync(lid);
+
+ return new UsAppLocationDetailOutputDto
+ {
+ LocationId = loc.Id.ToString(),
+ LocationName = string.IsNullOrWhiteSpace(loc.LocationName) ? "无" : loc.LocationName.Trim(),
+ FullAddress = BuildFullAddress(loc),
+ StorePhone = FormatStorePhoneDisplay(loc.Phone),
+ OperatingHours = "无",
+ ManagerName = mgrName,
+ ManagerPhone = mgrPhone
+ };
+ }
+
+ ///
+ [Authorize]
+ public virtual async Task GetMyProfileAsync()
+ {
+ if (!CurrentUser.Id.HasValue)
+ {
+ throw new UserFriendlyException("用户未登录");
+ }
+
+ var userId = CurrentUser.Id.Value;
+ var user = await _userRepository.GetByIdAsync(userId);
+ if (user is null || user.IsDeleted || !user.State)
+ {
+ throw new UserFriendlyException("用户不存在或已停用");
+ }
+
+ var roleRows = await _dbContext.SqlSugarClient.Queryable((ur, r) => ur.RoleId == r.Id)
+ .Where((ur, r) => ur.UserId == userId && !r.IsDeleted && r.State)
+ .OrderBy((ur, r) => r.OrderNum)
+ .Select((ur, r) => new { r.RoleName, r.RoleCode })
+ .ToListAsync();
+
+ var roleNames = roleRows.Select(x => x.RoleName?.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
+ var roleDisplay = roleNames.Count == 0 ? "无" : string.Join(", ", roleNames);
+ var primaryCode = roleRows.FirstOrDefault()?.RoleCode?.Trim();
+
+ var fullName = !string.IsNullOrWhiteSpace(user.Name?.Trim())
+ ? user.Name.Trim()
+ : (!string.IsNullOrWhiteSpace(user.Nick?.Trim()) ? user.Nick.Trim() : user.UserName.Trim());
+
+ return new UsAppMyProfileOutputDto
+ {
+ FullName = fullName,
+ Email = string.IsNullOrWhiteSpace(user.Email) ? "无" : user.Email.Trim(),
+ Phone = FormatPhoneDisplay(user.Phone),
+ EmployeeId = string.IsNullOrWhiteSpace(user.UserName) ? "无" : user.UserName.Trim(),
+ RoleDisplay = roleDisplay,
+ PrimaryRoleCode = string.IsNullOrWhiteSpace(primaryCode) ? null : primaryCode
+ };
+ }
+
+ ///
+ [Authorize]
+ [UnitOfWork]
+ public virtual async Task ChangePasswordAsync(UsAppChangePasswordInputVo input)
+ {
+ if (input is null)
+ {
+ throw new UserFriendlyException("入参不能为空");
+ }
+
+ if (!CurrentUser.Id.HasValue)
+ {
+ throw new UserFriendlyException("用户未登录");
+ }
+
+ var current = input.CurrentPassword ?? string.Empty;
+ var newPwd = input.NewPassword ?? string.Empty;
+ var confirm = input.ConfirmNewPassword ?? string.Empty;
+
+ if (string.IsNullOrWhiteSpace(current) || string.IsNullOrWhiteSpace(newPwd) || string.IsNullOrWhiteSpace(confirm))
+ {
+ throw new UserFriendlyException("请填写当前密码、新密码与确认密码");
+ }
+
+ if (!string.Equals(newPwd, confirm, StringComparison.Ordinal))
+ {
+ throw new UserFriendlyException("新密码与确认密码不一致");
+ }
+
+ if (string.Equals(current, newPwd, StringComparison.Ordinal))
+ {
+ throw new UserFriendlyException("新密码不能与当前密码相同");
+ }
+
+ ValidateAppPasswordComplexity(newPwd);
+
+ var userId = CurrentUser.Id.Value;
+ var user = await _userRepository.GetByIdAsync(userId);
+ if (user is null || user.IsDeleted || !user.State)
+ {
+ throw new UserFriendlyException("用户不存在或已停用");
+ }
+
+ if (!user.JudgePassword(current))
+ {
+ throw new UserFriendlyException(UserConst.Login_Error);
+ }
+
+ user.EncryPassword.Password = newPwd;
+ user.BuildPassword();
+ await _userRepository.UpdateAsync(user);
+ }
+
+ private static string FormatPhoneDisplay(long? phone)
+ {
+ if (!phone.HasValue)
+ {
+ return "无";
+ }
+
+ var digits = Math.Abs(phone.Value).ToString(CultureInfo.InvariantCulture);
+ if (digits.Length == 10)
+ {
+ return $"+1 ({digits[..3]}) {digits.Substring(3, 3)}-{digits.Substring(6, 4)}";
+ }
+
+ if (digits.Length == 11 && digits.StartsWith("1", StringComparison.Ordinal))
+ {
+ return $"+1 ({digits[1..4]}) {digits.Substring(4, 3)}-{digits.Substring(7, 4)}";
+ }
+
+ return $"+{digits}";
+ }
+
+ private static string FormatStorePhoneDisplay(string? phone)
+ {
+ var t = phone?.Trim();
+ return string.IsNullOrEmpty(t) ? "无" : t;
+ }
+
+ private async Task<(string Name, string Phone)> TryResolveStoreManagerAsync(string locationIdTrimmed)
+ {
+ var userIdStrings = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted && x.LocationId == locationIdTrimmed)
+ .Select(x => x.UserId)
+ .Distinct()
+ .ToListAsync();
+
+ var userGuids = userIdStrings
+ .Select(s => Guid.TryParse(s, out var g) ? (Guid?)g : null)
+ .Where(g => g.HasValue)
+ .Select(g => g!.Value)
+ .ToList();
+
+ if (userGuids.Count == 0)
+ {
+ return ("无", "无");
+ }
+
+ var rows = await _dbContext.SqlSugarClient.Queryable(
+ (u, ur, r) => u.Id == ur.UserId && ur.RoleId == r.Id)
+ .Where((u, ur, r) => userGuids.Contains(u.Id) && !u.IsDeleted && u.State)
+ .Where((u, ur, r) => !r.IsDeleted && r.State)
+ .Where((u, ur, r) =>
+ SqlFunc.ToLower(r.RoleCode).Contains("manager") ||
+ SqlFunc.ToLower(r.RoleName).Contains("manager"))
+ .OrderBy((u, ur, r) => u.Name)
+ .Select((u, ur, r) => new
+ {
+ u.Name,
+ u.Nick,
+ u.UserName,
+ u.Phone
+ })
+ .ToListAsync();
+
+ var row = rows.FirstOrDefault();
+ if (row is null)
+ {
+ return ("无", "无");
+ }
+
+ var displayName = !string.IsNullOrWhiteSpace(row.Name?.Trim())
+ ? row.Name!.Trim()
+ : (!string.IsNullOrWhiteSpace(row.Nick?.Trim())
+ ? row.Nick!.Trim()
+ : (row.UserName?.Trim() ?? "无"));
+
+ return (displayName, FormatPhoneDisplay(row.Phone));
+ }
+
+ private static void ValidateAppPasswordComplexity(string password)
+ {
+ if (password.Length < 8)
+ {
+ throw new UserFriendlyException("新密码至少 8 位");
+ }
+
+ if (!password.Any(char.IsUpper))
+ {
+ throw new UserFriendlyException("新密码需包含大写字母");
+ }
+
+ if (!password.Any(char.IsLower))
+ {
+ throw new UserFriendlyException("新密码需包含小写字母");
+ }
+
+ if (!password.Any(char.IsDigit))
+ {
+ throw new UserFriendlyException("新密码需包含至少一个数字");
+ }
+
+ if (!password.Any(c => !char.IsLetterOrDigit(c)))
+ {
+ throw new UserFriendlyException("新密码需包含至少一个特殊字符");
+ }
+ }
+
private void ValidationImageCaptcha(string? uuid, string? code)
{
if (!_rbacOptions.EnableCaptcha)
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
index bb294a3..7729567 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
+++ b/美国版/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
throw new UserFriendlyException("打印任务不存在");
}
- // 仅允许重打自己在当前门店的任务
- if (!string.Equals(old.CreatedBy?.Trim(), currentUserId, StringComparison.OrdinalIgnoreCase))
+ // 非 admin:仅允许重打自己在当前门店的任务;admin 可重打任意用户任务(仍须门店一致)
+ var isAdmin = ReportsRoleHelper.IsAdminRole(CurrentUser);
+ if (!isAdmin && !string.Equals(old.CreatedBy?.Trim(), currentUserId, StringComparison.OrdinalIgnoreCase))
{
throw new UserFriendlyException("无权限重打该任务");
}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_group_create.sql b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_group_create.sql
new file mode 100644
index 0000000..fa5c45a
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_group_create.sql
@@ -0,0 +1,21 @@
+-- 组织/分组(Group),归属合作伙伴(Parent Partner)
+-- 依赖:请先执行 fl_partner_create.sql,保证存在 fl_partner 表。
+
+CREATE TABLE IF NOT EXISTS `fl_group` (
+ `Id` varchar(64) NOT NULL COMMENT '主键',
+ `IsDeleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ `CreationTime` datetime(6) NOT NULL COMMENT '创建时间',
+ `CreatorId` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `LastModificationTime` datetime(6) DEFAULT NULL COMMENT '最后修改时间',
+ `LastModifierId` varchar(64) DEFAULT NULL COMMENT '最后修改人',
+ `GroupName` varchar(256) NOT NULL COMMENT '组织名称',
+ `PartnerId` varchar(64) NOT NULL COMMENT '所属合作伙伴 fl_partner.Id',
+ `State` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
+ PRIMARY KEY (`Id`),
+ KEY `IX_fl_group_IsDeleted` (`IsDeleted`),
+ KEY `IX_fl_group_State` (`State`),
+ KEY `IX_fl_group_PartnerId` (`PartnerId`),
+ KEY `IX_fl_group_GroupName` (`GroupName`(128)),
+ KEY `IX_fl_group_CreationTime` (`CreationTime`),
+ CONSTRAINT `FK_fl_group_partner` FOREIGN KEY (`PartnerId`) REFERENCES `fl_partner` (`Id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织(Group)';
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql
new file mode 100644
index 0000000..e4dee86
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql
@@ -0,0 +1,20 @@
+-- 合作伙伴主数据(美国版 antis-foodlabeling-us)
+-- 执行前请确认连接库为业务库;若表已存在请勿重复执行。
+
+CREATE TABLE IF NOT EXISTS `fl_partner` (
+ `Id` varchar(64) NOT NULL COMMENT '主键',
+ `IsDeleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ `CreationTime` datetime(6) NOT NULL COMMENT '创建时间',
+ `CreatorId` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `LastModificationTime` datetime(6) DEFAULT NULL COMMENT '最后修改时间',
+ `LastModifierId` varchar(64) DEFAULT NULL COMMENT '最后修改人',
+ `PartnerName` varchar(256) NOT NULL COMMENT '合作伙伴名称',
+ `ContactEmail` varchar(256) DEFAULT NULL COMMENT '联系邮箱',
+ `PhoneNumber` varchar(64) DEFAULT NULL COMMENT '电话',
+ `State` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
+ PRIMARY KEY (`Id`),
+ KEY `IX_fl_partner_IsDeleted` (`IsDeleted`),
+ KEY `IX_fl_partner_State` (`State`),
+ KEY `IX_fl_partner_CreationTime` (`CreationTime`),
+ KEY `IX_fl_partner_PartnerName` (`PartnerName`(128))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合作伙伴';
diff --git a/项目相关文档/Dashboard统计接口对接说明.md b/项目相关文档/Dashboard统计接口对接说明.md
index e62e28b..af28dbc 100644
--- a/项目相关文档/Dashboard统计接口对接说明.md
+++ b/项目相关文档/Dashboard统计接口对接说明.md
@@ -28,6 +28,7 @@
"weeklyPrintVolume": [],
"byCategory": [],
"byCategoryTotal": 0,
+ "recentLabels": [],
"generatedAt": "2026-04-22T10:00:00+08:00",
"metricCards": [],
@@ -39,6 +40,7 @@
说明:
- `labelsPrintedToday/activeTemplates/...`、`byCategory/byCategoryTotal` 是**前端直观命名**(推荐使用)。
- `metricCards`、`categoryDistribution`、`categoryDistributionTotal` 为**兼容字段**(与旧版返回一致)。
+- `recentLabels`:**Recent Labels** 区块数据,全门店按打印时间倒序取最新 **10** 条(`fl_label_print_task`)。
---
@@ -88,6 +90,25 @@
---
+### 3.4 最近打印标签(`recentLabels`)
+
+数组元素类型:`DashboardRecentLabelItemDto`,按 **`PrintedAt`(`PrintedAt` 为空则用 `CreationTime`)倒序**,最多 **10** 条。
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `taskId` | string | 打印任务 Id(`fl_label_print_task.Id`) |
+| `labelCode` | string | 标签编码(界面 Serial,如 `1-251201`) |
+| `displayName` | string | 展示标题:优先 **产品名**,否则 **标签名** |
+| `printedByUserId` | string \| null | 打印人用户 Id(`CreatedBy`) |
+| `printedByName` | string | 打印人展示名(`User.Name` 或 `UserName`) |
+| `printedAt` | string (datetime) | 打印时间(ISO 8601) |
+| `status` | string | `active` 或 `expired`:从 `PrintInputJson` 解析 `expiryDate` / `expiry` / `expirationDate`,与**当天日期**比较;无保质期或解析失败视为 `active` |
+| `labelTypeBadge` | string | 模板尺寸短文案(如 `2"x2"`),用于左侧圆标 |
+
+前端可用 `printedAt` 自行格式化为「10 mins ago」等相对时间。
+
+---
+
## 4. 返回示例
```json
@@ -155,6 +176,18 @@
{ "categoryId": "CAT003", "categoryName": "Dinner", "count": 230, "ratio": 23.00 }
],
"byCategoryTotal": 1000,
+ "recentLabels": [
+ {
+ "taskId": "…",
+ "labelCode": "1-251201",
+ "displayName": "Chicken Breast",
+ "printedByUserId": "…",
+ "printedByName": "Alice J.",
+ "printedAt": "2026-04-22T09:50:00+08:00",
+ "status": "active",
+ "labelTypeBadge": "2\"x2\""
+ }
+ ],
"generatedAt": "2026-04-22T10:00:00+08:00",
"metricCards": [],
"categoryDistribution": [],
diff --git a/项目相关文档/合作伙伴Partner接口对接说明.md b/项目相关文档/合作伙伴Partner接口对接说明.md
new file mode 100644
index 0000000..325e6dc
--- /dev/null
+++ b/项目相关文档/合作伙伴Partner接口对接说明.md
@@ -0,0 +1,417 @@
+# 合作伙伴(Partner)与组织(Group)接口对接说明
+
+> 适用范围:美国版 Web 管理端「Account Management」下的 **Partner**、**Group** 主数据
+> **Partner** 表:`fl_partner`,接口:`IPartnerAppService` / `PartnerAppService`
+> **Group** 表:`fl_group`(`PartnerId` 关联 `fl_partner.Id`),接口:`IGroupAppService` / `GroupAppService`
+> 宿主路由前缀:`/api/app`(与 `YiAbpWebModule` 中 `RootPath = api/app` 一致)
+
+---
+
+## 0. 通用说明
+
+- **鉴权**:需要登录(`Authorization: Bearer {token}`),与其它 `/api/app/*` 接口一致。
+- **Content-Type**:`POST` / `PUT` 使用 `application/json`。
+- **分页约定(美国版食品标签模块)**:`skipCount` 表示**页码(从 1 起)**,不是 0 基 offset;第一页请传 `skipCount=1`。与 `PagedQueryConvention` 及 SqlSugar `ToPageListAsync` 用法一致。
+- **逻辑删除**:`DELETE` 将对应表的 `IsDeleted` 置为 `true`(`fl_partner` / `fl_group`),不物理删行。
+- **列表与导出筛选一致**:各模块列表的筛选字段与对应 **export-pdf** 接口一致,便于数据对齐。
+- **Group 与 Partner**:新建/编辑 Group 时 `partnerId` 必须指向**未逻辑删除**的 `fl_partner`;列表左联 Partner 时仅展示未删除合作伙伴名称,已删 Partner 在列表中显示为 **`无`**。
+
+---
+
+# 第一部分:Partner(合作伙伴)
+
+## 1. 分页列表
+
+- **方法**:`GET`
+- **路径**:`/api/app/partner`
+
+### 1.1 查询参数(`PartnerGetListInputVo`)
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `skipCount` | int | 是 | 页码,从 **1** 开始 |
+| `maxResultCount` | int | 是 | 每页条数 |
+| `sorting` | string | 否 | 排序,仅支持白名单(见下) |
+| `keyword` | string | 否 | 模糊匹配 `PartnerName`、`ContactEmail`、`PhoneNumber` |
+| `state` | bool | 否 | 按启用状态筛选;不传则不过滤 |
+
+**排序白名单**(大小写不敏感):
+
+- `PartnerName asc` / `PartnerName desc`
+- `CreationTime asc` / `CreationTime desc`
+- `State asc` / `State desc`
+
+其它值将回退为默认:**按 `CreationTime` 降序**。
+
+### 1.2 请求示例
+
+```http
+GET /api/app/partner?skipCount=1&maxResultCount=10&keyword=Global&state=true&sorting=CreationTime%20desc HTTP/1.1
+Authorization: Bearer {token}
+```
+
+### 1.3 响应结构(`PagedResultWithPageDto`)
+
+```json
+{
+ "pageIndex": 1,
+ "pageSize": 10,
+ "totalCount": 2,
+ "totalPages": 1,
+ "items": [
+ {
+ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "partnerName": "Global Foods Inc.",
+ "contactEmail": "admin@globalfoods.com",
+ "phoneNumber": "+1 (555) 100-2000",
+ "state": true,
+ "creationTime": "2026-04-27T10:00:00"
+ }
+ ]
+}
+```
+
+### 1.4 列表项字段(`PartnerGetListOutputDto`)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 主键 |
+| `partnerName` | string | 合作伙伴名称 |
+| `contactEmail` | string \| null | 联系邮箱 |
+| `phoneNumber` | string \| null | 电话 |
+| `state` | bool | 是否启用(UI Active 开关) |
+| `creationTime` | string (datetime) | 创建时间 |
+
+---
+
+## 2. 详情
+
+- **方法**:`GET`
+- **路径**:`/api/app/partner/{id}`
+
+### 2.1 路径参数
+
+| 参数 | 说明 |
+|------|------|
+| `id` | `fl_partner.Id` |
+
+### 2.2 响应结构(`PartnerGetOutputDto`)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 主键 |
+| `partnerName` | string | 合作伙伴名称 |
+| `contactEmail` | string \| null | 联系邮箱 |
+| `phoneNumber` | string \| null | 电话 |
+| `state` | bool | 是否启用 |
+| `creationTime` | string (datetime) | 创建时间 |
+| `lastModificationTime` | string (datetime) \| null | 最后修改时间 |
+
+### 2.3 错误说明
+
+- `id` 为空或记录不存在(含已逻辑删除):业务错误提示「合作伙伴不存在」等。
+
+---
+
+## 3. 新增
+
+- **方法**:`POST`
+- **路径**:`/api/app/partner`
+
+### 3.1 Body(`PartnerCreateInputVo`)
+
+```json
+{
+ "partnerName": "Global Foods Inc.",
+ "contactEmail": "admin@globalfoods.com",
+ "phoneNumber": "+1 (555) 100-2000",
+ "state": true
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `partnerName` | string | 是 | 合作伙伴名称,去首尾空格后不能为空 |
+| `contactEmail` | string | 否 | 若填写则做简单格式校验(含 `@` 等) |
+| `phoneNumber` | string | 否 | 电话 |
+| `state` | bool | 否 | 默认 `true` |
+
+### 3.2 响应
+
+- 成功:返回 `PartnerGetOutputDto`(与详情结构一致)。
+
+---
+
+## 4. 编辑
+
+- **方法**:`PUT`
+- **路径**:`/api/app/partner/{id}`
+
+### 4.1 参数
+
+- **Path**:`id` 为当前合作伙伴主键。
+- **Body**:`PartnerUpdateInputVo`,字段与新增相同。
+
+```json
+{
+ "partnerName": "Global Foods Inc.",
+ "contactEmail": "admin@globalfoods.com",
+ "phoneNumber": "+1 (555) 100-2000",
+ "state": false
+}
+```
+
+### 4.2 响应
+
+- 成功:返回更新后的 `PartnerGetOutputDto`。
+
+---
+
+## 5. 删除(逻辑删除)
+
+- **方法**:`DELETE`
+- **路径**:`/api/app/partner/{id}`
+
+### 5.1 路径参数
+
+| 参数 | 说明 |
+|------|------|
+| `id` | `fl_partner.Id` |
+
+### 5.2 行为
+
+- 将 `IsDeleted` 置为 `true`,并更新 `LastModificationTime` / `LastModifierId`(若当前用户存在)。
+
+---
+
+## 6. 批量导出 PDF
+
+- **方法**:`GET`
+- **路径**:`/api/app/partner/export-pdf`
+- **响应**:`Content-Type: application/pdf`,附件名形如 `partners_yyyy-MM-dd_HH-mm-ss.pdf`
+
+### 6.1 查询参数
+
+与列表接口相同的筛选字段(分页字段可忽略):
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `keyword` | string | 否 | 与列表 `keyword` 一致 |
+| `state` | bool | 否 | 与列表 `state` 一致 |
+| `sorting` | string | 否 | 与列表白名单一致,用于导出行顺序 |
+
+### 6.2 限制
+
+- 命中行数 **超过 5000** 时接口返回业务错误,需缩小筛选范围后再导出。
+- 导出最多取 **5000** 条,排序与列表查询逻辑一致。
+
+### 6.3 请求示例
+
+```http
+GET /api/app/partner/export-pdf?keyword=Global&state=true HTTP/1.1
+Authorization: Bearer {token}
+```
+
+### 6.4 PDF 内容说明
+
+- 表头列:**Partner**、**Contact**、**Phone**、**Status**、**Created**。
+- `Status` 文本:`state === true` 时为 `active`,否则 `inactive`。
+- 空邮箱、空电话在 PDF 中显示为 **`无`**(与项目列表空值展示约定一致)。
+
+---
+
+## 7. 数据库与建表(Partner)
+
+- 建表脚本:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_partner_create.sql`
+- 主要字段:`Id`、`IsDeleted`、`CreationTime`、`CreatorId`、`LastModificationTime`、`LastModifierId`、`PartnerName`、`ContactEmail`、`PhoneNumber`、`State`
+
+---
+
+## 8. 与门店字段的关系说明
+
+- 门店(`location`)上可能存在 **`Partner` 字符串字段**(原型/筛选用),与本文 **`fl_partner` 主数据表** 无强制外键关联。
+- 若后续要将门店关联到合作伙伴主数据,需单独产品方案(例如增加 `PartnerId` 或同步名称)。
+
+---
+
+## 9. 前端对接提示(Partner)
+
+- 列表「Search」对应 `keyword`;「Active」筛选对应 `state`。
+- 「Bulk Export (PDF)」调用 **第 6 节** 导出接口,查询参数与当前列表筛选保持一致即可。
+
+---
+
+# 第二部分:Group(组织 / Group)
+
+> UI:**Group Name**、**Parent Partner**(下拉绑定合作伙伴)、**Status**、**Bulk Export (PDF)**、**New+** 弹窗(Group Name、Assign to Partner、Active)。
+
+## 10. 数据库与建表(Group)
+
+- 库中原先**无**独立 Group 业务表;新建表名:`fl_group`。
+- 建表脚本:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_group_create.sql`
+- **须先存在 `fl_partner` 表**(脚本内含外键 `FK_fl_group_partner` → `fl_partner(Id)`)。
+- 主要字段:`Id`、`IsDeleted`、`CreationTime`、`CreatorId`、`LastModificationTime`、`LastModifierId`、`GroupName`、`PartnerId`、`State`
+
+**建表 SQL(与脚本文件一致,便于直接执行):**
+
+```sql
+CREATE TABLE IF NOT EXISTS `fl_group` (
+ `Id` varchar(64) NOT NULL COMMENT '主键',
+ `IsDeleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ `CreationTime` datetime(6) NOT NULL COMMENT '创建时间',
+ `CreatorId` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `LastModificationTime` datetime(6) DEFAULT NULL COMMENT '最后修改时间',
+ `LastModifierId` varchar(64) DEFAULT NULL COMMENT '最后修改人',
+ `GroupName` varchar(256) NOT NULL COMMENT '组织名称',
+ `PartnerId` varchar(64) NOT NULL COMMENT '所属合作伙伴 fl_partner.Id',
+ `State` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
+ PRIMARY KEY (`Id`),
+ KEY `IX_fl_group_IsDeleted` (`IsDeleted`),
+ KEY `IX_fl_group_State` (`State`),
+ KEY `IX_fl_group_PartnerId` (`PartnerId`),
+ KEY `IX_fl_group_GroupName` (`GroupName`(128)),
+ KEY `IX_fl_group_CreationTime` (`CreationTime`),
+ CONSTRAINT `FK_fl_group_partner` FOREIGN KEY (`PartnerId`) REFERENCES `fl_partner` (`Id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织(Group)';
+```
+
+---
+
+## 11. Group — 分页列表
+
+- **方法**:`GET`
+- **路径**:`/api/app/group`
+
+### 11.1 查询参数(`GroupGetListInputVo`)
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `skipCount` | int | 是 | 页码,从 **1** 开始 |
+| `maxResultCount` | int | 是 | 每页条数 |
+| `sorting` | string | 否 | 排序白名单(见下) |
+| `keyword` | string | 否 | 模糊匹配 `GroupName`、所属 **未删除** Partner 的 `PartnerName` |
+| `partnerId` | string | 否 | 仅查看某合作伙伴下的组织(`fl_partner.Id`) |
+| `state` | bool | 否 | 按启用状态筛选;不传则不过滤 |
+
+**排序白名单**(大小写不敏感):
+
+- `GroupName asc` / `GroupName desc`
+- `CreationTime asc` / `CreationTime desc`
+- `State asc` / `State desc`
+- `PartnerName asc` / `PartnerName desc`(按关联合作伙伴名称)
+
+其它值回退为默认:**按 `CreationTime` 降序**。
+
+### 11.2 请求示例
+
+```http
+GET /api/app/group?skipCount=1&maxResultCount=10&keyword=West&partnerId={partnerGuid}&state=true HTTP/1.1
+Authorization: Bearer {token}
+```
+
+### 11.3 响应结构(`PagedResultWithPageDto`)
+
+```json
+{
+ "pageIndex": 1,
+ "pageSize": 10,
+ "totalCount": 2,
+ "totalPages": 1,
+ "items": [
+ {
+ "id": "…",
+ "groupName": "West Coast Region",
+ "partnerId": "…",
+ "partnerName": "Global Foods Inc.",
+ "state": true,
+ "creationTime": "2026-04-27T10:00:00"
+ }
+ ]
+}
+```
+
+### 11.4 列表项字段
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 主键 |
+| `groupName` | string | 组织名称 |
+| `partnerId` | string | 所属合作伙伴 Id |
+| `partnerName` | string | 父级合作伙伴名称(UI「Parent Partner」) |
+| `state` | bool | 是否启用 |
+| `creationTime` | string (datetime) | 创建时间 |
+
+---
+
+## 12. Group — 详情
+
+- **方法**:`GET`
+- **路径**:`/api/app/group/{id}`
+
+路径参数 `id`:`fl_group.Id`。响应为 `GroupGetOutputDto`(在列表字段基础上增加 `lastModificationTime`)。
+
+---
+
+## 13. Group — 新增
+
+- **方法**:`POST`
+- **路径**:`/api/app/group`
+
+### Body(`GroupCreateInputVo`)
+
+```json
+{
+ "groupName": "West Coast Region",
+ "partnerId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "state": true
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `groupName` | string | 是 | 组织名称 |
+| `partnerId` | string | 是 | Assign to Partner,须为未删除的 `fl_partner.Id` |
+| `state` | bool | 否 | 默认 `true` |
+
+---
+
+## 14. Group — 编辑
+
+- **方法**:`PUT`
+- **路径**:`/api/app/group/{id}`
+- **Body**:`GroupUpdateInputVo`(字段同新增)
+
+---
+
+## 15. Group — 删除(逻辑删除)
+
+- **方法**:`DELETE`
+- **路径**:`/api/app/group/{id}`
+
+---
+
+## 16. Group — 批量导出 PDF
+
+- **方法**:`GET`
+- **路径**:`/api/app/group/export-pdf`
+- **响应**:`application/pdf`,附件名形如 `groups_yyyy-MM-dd_HH-mm-ss.pdf`
+
+### 16.1 查询参数
+
+与列表一致(分页可忽略):`keyword`、`partnerId`、`state`、`sorting`。
+
+### 16.2 限制
+
+- 命中行数 **超过 5000** 返回业务错误;导出最多 **5000** 条。
+
+### 16.3 PDF 列
+
+**Group Name**、**Parent Partner**、**Status**(`active` / `inactive`)、**Created**。
+
+---
+
+## 17. 前端对接提示(Group)
+
+- 「Search」→ `keyword`;按父级合作伙伴筛选 → `partnerId`(下拉选中项的 `id`);「Active」→ `state`。
+- 「Assign to Partner」下拉数据来自 **Partner 列表接口**(`/api/app/partner`)。
+- 「Bulk Export (PDF)」→ **第 16 节**,查询参数与当前列表筛选一致。
diff --git a/项目相关文档/报表Reports接口对接说明.md b/项目相关文档/报表Reports接口对接说明.md
new file mode 100644
index 0000000..7e55269
--- /dev/null
+++ b/项目相关文档/报表Reports接口对接说明.md
@@ -0,0 +1,112 @@
+# 报表 Reports 接口对接说明(Print Log / Label Report)
+
+> 适用范围:美国版 Web「Reports」— **Print Log** 列表与导出、**Label Report** 统计与导出、**重打**
+> 实现:`IReportsAppService` / `ReportsAppService`;重打复用 `IUsAppLabelingAppService.ReprintAsync`(已支持 admin 跳过创建人校验)
+> 路由前缀:`/api/app`
+
+---
+
+## 0. 角色与数据范围(必读)
+
+- 判断依据:`CurrentUser.Roles` 中是否存在**忽略大小写**等于 **`admin`** 的角色码(与 JWT 中角色码一致,参见 `AuthSessionAppService` / `ReportsRoleHelper`)。
+- **`admin`**:**不按** `CreatedBy` 过滤,可查看/统计全部 `fl_label_print_task`(仍受 Partner/Group/Location/日期/关键字筛选)。
+- **非 `admin`**:所有列表与统计仅包含 **`CreatedBy == 当前用户 Id`** 的打印任务。
+- **重打**:非 admin 仅能重打本人任务;**`admin` 可重打任意用户任务**,但仍须 `locationId` 与历史任务一致(与 App 重打规则一致)。
+
+---
+
+## 1. Partner / Group / Location 筛选说明
+
+- **`locationId`**:若传则只查该门店(`location.Id` 字符串)。
+- **`partnerId`**(`fl_partner.Id`):按合作伙伴名称与 `location.Partner` **文本全等**(trim 后)匹配,得到门店集合再过滤任务。
+- **`groupId`**(`fl_group.Id`):按组织的 `GroupName` + 父级 `PartnerName` 与门店的 `GroupName` + `Partner` **文本全等**匹配门店。
+- 若 Partner/Group 在库中不存在,返回**空列表/空统计**(不报错)。
+- 未传 Partner/Group/Location 时:**不按门店集合预过滤**(仅日期、关键字、用户范围生效)。
+
+---
+
+## 2. Print Log — 分页查询
+
+- **方法**:`GET`
+- **路径**:`/api/app/reports/print-log-list`(以 Swagger 为准;ABP 约定多为 `get-print-log-list` 映射到该路径)
+
+### 2.1 查询参数(`ReportsPrintLogGetListInputVo`)
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| `skipCount` | int | 页码,从 **1** 起 |
+| `maxResultCount` | int | 每页条数 |
+| `sorting` | string | 可选:`PrintedAt asc` / `PrintedAt desc`(默认倒序) |
+| `partnerId` | string | 可选 |
+| `groupId` | string | 可选 |
+| `locationId` | string | 可选 |
+| `startDate` | date | 可选;默认与结束日组成约 30 天窗口 |
+| `endDate` | date | 可选;默认今天(含当日) |
+| `keyword` | string | 可选;匹配产品名、标签分类名、产品分类名(模糊) |
+
+### 2.2 响应项(`ReportsPrintLogListItemDto`)
+
+含:`taskId`、`labelCode`、`productName`、`categoryName`、`templateText`、`printedAt`、`printedByName`、`locationText`、`locationId`(重打必填)、`expiryDateText`(从 `PrintInputJson` 中尝试解析 `expiryDate` / `expiry` / `expirationDate`)。
+
+---
+
+## 3. Print Log — 导出 PDF
+
+- **方法**:`GET`
+- **路径**:`/api/app/reports/export-print-log-pdf`
+- **查询参数**:与 **§2** 相同(分页字段忽略);最多 **5000** 条,超出返回业务错误。
+
+---
+
+## 4. Print Log — 重打(Reprint)
+
+- **方法**:`POST`
+- **路径**:`/api/app/reports/reprint-print-log`(实现内转发至 `UsAppLabelingAppService.ReprintAsync`,以 Swagger 为准)
+- **Body**:`UsAppLabelReprintInputVo`:`locationId`、`taskId`、`printQuantity`、`clientRequestId`(可选)、打印机字段(可选)。
+
+---
+
+## 5. Label Report — 统计聚合
+
+- **方法**:`GET`
+- **路径**:`/api/app/reports/label-report`(以 Swagger 为准)
+
+### 5.1 查询参数(`ReportsLabelReportQueryInputVo`)
+
+与 Print Log 相同的 `partnerId`、`groupId`、`locationId`、`startDate`、`endDate`、`keyword`(无分页)。
+
+### 5.2 默认时间窗
+
+未传日期时:**结束日 = 今天,开始日 = 结束日前推 29 天(共约 30 个自然日,含首尾)**。
+
+### 5.3 返回(`ReportsLabelReportOutputDto`)
+
+- **`summary`**:`totalLabelsPrinted`、上一同长周期 `totalLabelsPrintedPrevPeriod`、`totalLabelsPrintedChangeRate`(%);最热门标签分类名与次数;Top 产品名与次数;`avgDailyPrints` 及环比等。
+- **`labelsByCategory`**:按 **标签分类**(`fl_label_category`)汇总当前区间内打印次数。
+- **`printVolumeTrend`**:在**当前筛选日期区间内**,取**结束日向前最多 7 个自然日**(与区间求交)的按日打印量。
+- **`mostUsedProducts`**:当前区间内产品打印次数 Top20,`usagePercent` 为占**当期总打印次数**的百分比。
+
+---
+
+## 6. Label Report — 导出 PDF
+
+- **方法**:`GET`
+- **路径**:`/api/app/reports/export-label-report-pdf`
+- **查询参数**:与 **§5** 相同;内容为摘要 + 分类表 + 日趋势表 + Top 产品表。
+
+---
+
+## 7. 数据表与字段依赖
+
+- 打印任务:`fl_label_print_task`(`CreatedBy`、`LocationId`、`PrintedAt`、`PrintInputJson` 等)
+- 门店:`location`(`Partner`、`GroupName`、`LocationCode`、`LocationName`)
+- 主数据:`fl_partner`、`fl_group`(筛选用)
+- 用户:`User`(展示 `PrintedByName`)
+
+---
+
+## 8. 前端对接提示
+
+- Print Log 行内 **Reprint**:使用列表返回的 `locationId` + `taskId` 调重打接口。
+- 「Export Report」在 Print Log Tab 调 **§3**;在 Label Report Tab 调 **§6**。
+- 下拉 **Partner / Group / Location** 与列表筛选字段一致;需保证门店维护的 `Partner` / `GroupName` 与主数据名称一致,否则筛选结果为空。
diff --git a/项目相关文档/美国版App登录接口说明.md b/项目相关文档/美国版App登录接口说明.md
index fd04601..9da9fa1 100644
--- a/项目相关文档/美国版App登录接口说明.md
+++ b/项目相关文档/美国版App登录接口说明.md
@@ -149,6 +149,142 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
---
+## 接口 3:我的资料(My Profile)
+
+用于「My Profile」页展示:全名、邮箱、电话、员工号、角色。
+
+### HTTP
+
+- **方法**:`GET`
+- **路径**:`/api/app/us-app-auth/my-profile`(以 Swagger 中 `UsAppAuth` 为准)
+- **鉴权**:需要登录(`Authorization: Bearer {token}`)
+
+### 请求参数
+
+无。
+
+### 响应体(UsAppMyProfileOutputDto)
+
+| 字段(JSON) | 类型 | 说明 |
+|--------------|------|------|
+| `fullName` | string | `User.Name`,无则 `Nick`,再无则 `UserName` |
+| `email` | string | `User.Email`;空为 `"无"` |
+| `phone` | string | 由 `User.Phone`(long)格式化为可读字符串;10 位数字按 `+1 (555) 123-4567` 形式;无则 `"无"` |
+| `employeeId` | string | 当前取 **`User.UserName`**(可与业务约定为工号/登录名) |
+| `roleDisplay` | string | 多角色 `Role.RoleName` 按 `OrderNum` 排序后以英文逗号拼接;无角色为 `"无"` |
+| `primaryRoleCode` | string \| null | 第一个角色的 `RoleCode`,供前端样式 |
+
+---
+
+## 接口 4:修改密码(Change Password)
+
+与 **`User.JudgePassword` / `BuildPassword`** 一致:校验当前密码后写入新盐值哈希。
+
+### HTTP
+
+- **方法**:`POST`
+- **路径**:`/api/app/us-app-auth/change-password`
+- **Content-Type**:`application/json`
+- **鉴权**:需要登录
+
+### 请求体(UsAppChangePasswordInputVo)
+
+| 字段(JSON) | 类型 | 必填 | 说明 |
+|--------------|------|------|------|
+| `currentPassword` | string | 是 | 当前明文密码 |
+| `newPassword` | string | 是 | 新密码 |
+| `confirmNewPassword` | string | 是 | 必须与 `newPassword` **完全一致** |
+
+### 新密码复杂度(与原型「Password Requirements」一致)
+
+1. 至少 **8** 位
+2. 至少 **1** 个大写字母、**1** 个小写字母
+3. 至少 **1** 个数字
+4. 至少 **1** 个**非字母数字**字符(特殊字符)
+
+### 请求示例
+
+```json
+{
+ "currentPassword": "OldPass1!",
+ "newPassword": "NewPass9@x",
+ "confirmNewPassword": "NewPass9@x"
+}
+```
+
+### 响应
+
+成功时 HTTP 200,无业务体要求(ABP 可能返回空对象);失败为业务异常文案。
+
+### 常见错误
+
+- 未登录:`用户未登录`
+- 当前密码错误:与登录失败一致(`UserConst.Login_Error` 文案)
+- 新密码与确认不一致:`新密码与确认密码不一致`
+- 新密码与当前相同:`新密码不能与当前密码相同`
+- 不满足复杂度:如 `新密码至少 8 位`、`新密码需包含大写字母` 等
+
+---
+
+## 接口 5:门店详情(Location)
+
+用于移动端「Location」页:店名、完整地址、门店电话、营业时间占位、店长姓名与电话。**仅当当前 JWT 用户在 `userlocation` 中绑定该门店**时可查(与接口 1、2 的门店范围一致)。
+
+### HTTP
+
+- **方法**:`GET`
+- **路径**:`/api/app/us-app-auth/location-detail`(以 Swagger 中 `UsAppAuth` → `GetLocationDetail` 为准;`locationId` 一般为 **Query** 参数)
+- **鉴权**:需要登录(`Authorization: Bearer {token}`)
+
+### 请求参数
+
+| 参数名 | 位置 | 类型 | 必填 | 说明 |
+|--------|------|------|------|------|
+| `locationId` | Query | string | 是 | 门店主键,Guid 字符串(与 `locations[].id` 一致) |
+
+### 传参示例
+
+```http
+GET /api/app/us-app-auth/location-detail?locationId=a2696b9e-2277-11f1-b4c6-00163e0c7c4f HTTP/1.1
+Host: localhost:19001
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+```
+
+### 响应体(UsAppLocationDetailOutputDto)
+
+| 字段(JSON) | 类型 | 说明 |
+|--------------|------|------|
+| `locationId` | string | 门店主键 |
+| `locationName` | string | 门店名称;空为 `"无"` |
+| `fullAddress` | string | 与登录接口相同的地址拼接规则;无有效片段为 `"无"` |
+| `storePhone` | string | `location.Phone` 去首尾空格;空为 `"无"` |
+| `operatingHours` | string | 当前 **`location` 表无营业时间字段**,固定返回 **`"无"`**(后续若落库可再对接) |
+| `managerName` | string | 在同店 `userlocation` 绑定用户中,取角色 `RoleCode` / `RoleName`(忽略大小写)包含 **`manager`** 的第一人展示姓名(`Name` → `Nick` → `UserName`);无匹配为 **`"无"`** |
+| `managerPhone` | string | 同上用户的 `User.Phone`(long)按与「我的资料」相同的可读格式;无电话为 **`"无"`** |
+
+### 响应示例
+
+```json
+{
+ "locationId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",
+ "locationName": "Downtown Store",
+ "fullAddress": "123 Main St, New York, NY 10001",
+ "storePhone": "(212) 555-0100",
+ "operatingHours": "无",
+ "managerName": "Jane Doe",
+ "managerPhone": "+1 (555) 123-4567"
+}
+```
+
+### 常见错误(业务异常文案)
+
+- 未登录:`用户未登录`
+- `locationId` 非法或空:`无效的门店标识`
+- 当前用户未绑定该门店:`当前账号未绑定该门店,无法查看`
+- 门店已删或不存在:`门店不存在或已删除`
+
+---
+
## 与其他登录方式的区别
| 场景 | 说明 |