diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBatchImportInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBatchImportInputVo.cs
new file mode 100644
index 0000000..7629f6c
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBatchImportInputVo.cs
@@ -0,0 +1,16 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace FoodLabeling.Application.Contracts.Dtos.Location;
+
+///
+/// Location Manager 批量导入(Excel)
+///
+public class LocationBatchImportInputVo
+{
+ ///
+ /// 上传的 Excel 文件(与模板列一致)
+ ///
+ [FromForm(Name = "file")]
+ public IFormFile File { get; set; } = default!;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBatchImportResultDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBatchImportResultDto.cs
new file mode 100644
index 0000000..4d962c6
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBatchImportResultDto.cs
@@ -0,0 +1,24 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Location;
+
+///
+/// Location 批量导入结果
+///
+public class LocationBatchImportResultDto
+{
+ public int SuccessCount { get; set; }
+
+ public int FailCount { get; set; }
+
+ public int SkippedEmptyRows { get; set; }
+
+ public List Errors { get; set; } = new();
+}
+
+public class LocationBatchImportErrorDto
+{
+ public int RowNumber { get; set; }
+
+ public string? LocationCode { get; set; }
+
+ public string Message { get; set; } = string.Empty;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBulkUpdateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBulkUpdateInputVo.cs
new file mode 100644
index 0000000..6a64130
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBulkUpdateInputVo.cs
@@ -0,0 +1,23 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Location;
+
+///
+/// 门店批量编辑(网格「保存全部」)请求体
+///
+public class LocationBulkUpdateInputVo
+{
+ ///
+ /// 待保存的行;id 为 。可含空行:忽略 的项。
+ ///
+ public List Items { get; set; } = new();
+}
+
+///
+/// 单行编辑数据:主键 + 与单条 PUT /location/{id} 相同的可编辑字段。
+///
+public class LocationBulkUpdateItemVo : LocationUpdateInputVo
+{
+ ///
+ /// 门店主键(非空、非 Empty 时才会更新)
+ ///
+ public Guid Id { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBulkUpdateResultDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBulkUpdateResultDto.cs
new file mode 100644
index 0000000..62574c6
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBulkUpdateResultDto.cs
@@ -0,0 +1,28 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Location;
+
+///
+/// 门店批量编辑结果
+///
+public class LocationBulkUpdateResultDto
+{
+ public int SuccessCount { get; set; }
+
+ public int FailCount { get; set; }
+
+ public List Errors { get; set; } = new();
+}
+
+///
+/// 批量编辑中单条失败信息
+///
+public class LocationBulkUpdateErrorDto
+{
+ ///
+ /// 在请求 items 数组中的序号(从 1 开始,与前端网格行对应)
+ ///
+ public int RowNumber { get; set; }
+
+ public Guid Id { get; set; }
+
+ public string Message { get; set; } = string.Empty;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportInputVo.cs
new file mode 100644
index 0000000..284d2cf
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportInputVo.cs
@@ -0,0 +1,16 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace FoodLabeling.Application.Contracts.Dtos.Product;
+
+///
+/// Product 批量导入(Excel)
+///
+public class ProductBatchImportInputVo
+{
+ ///
+ /// 上传的 Excel(列:Location / Product Category / Product / Product Code)
+ ///
+ [FromForm(Name = "file")]
+ public IFormFile File { get; set; } = default!;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportResultDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportResultDto.cs
new file mode 100644
index 0000000..2f54091
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportResultDto.cs
@@ -0,0 +1,19 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Product;
+
+public class ProductBatchImportResultDto
+{
+ public int SuccessCount { get; set; }
+
+ public int FailCount { get; set; }
+
+ public List Errors { get; set; } = new();
+}
+
+public class ProductBatchImportErrorDto
+{
+ public int RowNumber { get; set; }
+
+ public string? ProductName { get; set; }
+
+ public string Message { get; set; } = string.Empty;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBulkUpdateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBulkUpdateInputVo.cs
new file mode 100644
index 0000000..1190b56
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBulkUpdateInputVo.cs
@@ -0,0 +1,17 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Product;
+
+///
+/// Product 批量编辑(网格保存全部)
+///
+public class ProductBulkUpdateInputVo
+{
+ public List Items { get; set; } = new();
+}
+
+///
+/// 单行:产品主键(字符串 Guid)+ 与单条 PUT 相同的 body 字段。
+///
+public class ProductBulkUpdateItemVo : ProductUpdateInputVo
+{
+ public string Id { get; set; } = string.Empty;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBulkUpdateResultDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBulkUpdateResultDto.cs
new file mode 100644
index 0000000..dae6ae5
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBulkUpdateResultDto.cs
@@ -0,0 +1,19 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Product;
+
+public class ProductBulkUpdateResultDto
+{
+ public int SuccessCount { get; set; }
+
+ public int FailCount { get; set; }
+
+ public List Errors { get; set; } = new();
+}
+
+public class ProductBulkUpdateErrorDto
+{
+ public int RowNumber { get; set; }
+
+ public string Id { get; set; } = string.Empty;
+
+ public string Message { get; set; } = string.Empty;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportInputVo.cs
new file mode 100644
index 0000000..3462bed
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportInputVo.cs
@@ -0,0 +1,16 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
+
+///
+/// Team Member 批量导入(Excel)
+///
+public class TeamMemberBatchImportInputVo
+{
+ ///
+ /// 上传的 Excel 文件(与模板列一致)
+ ///
+ [FromForm(Name = "file")]
+ public IFormFile File { get; set; } = default!;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportResultDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportResultDto.cs
new file mode 100644
index 0000000..5a29051
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportResultDto.cs
@@ -0,0 +1,22 @@
+namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
+
+///
+/// Team Member 批量导入结果
+///
+public class TeamMemberBatchImportResultDto
+{
+ public int SuccessCount { get; set; }
+
+ public int FailCount { get; set; }
+
+ public List Errors { get; set; } = new();
+}
+
+public class TeamMemberBatchImportErrorDto
+{
+ public int RowNumber { get; set; }
+
+ public string? UserName { get; set; }
+
+ public string Message { get; set; } = string.Empty;
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBulkUpdateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBulkUpdateInputVo.cs
new file mode 100644
index 0000000..ae52006
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBulkUpdateInputVo.cs
@@ -0,0 +1,20 @@
+namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
+
+///
+/// Team Member 批量编辑(网格「保存全部」)请求体
+///
+public class TeamMemberBulkUpdateInputVo
+{
+ ///
+ /// 待保存的行;id 为成员主键。可含空行:忽略 id 为全零 GUID 的项。
+ ///
+ public List Items { get; set; } = new();
+}
+
+///
+/// 单行:主键 + 与单条 PUT 更新相同的字段。
+///
+public class TeamMemberBulkUpdateItemVo : TeamMemberUpdateInputVo
+{
+ public Guid Id { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBulkUpdateResultDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBulkUpdateResultDto.cs
new file mode 100644
index 0000000..4fb7f9f
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBulkUpdateResultDto.cs
@@ -0,0 +1,19 @@
+namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
+
+public class TeamMemberBulkUpdateResultDto
+{
+ public int SuccessCount { get; set; }
+
+ public int FailCount { get; set; }
+
+ public List Errors { get; set; } = new();
+}
+
+public class TeamMemberBulkUpdateErrorDto
+{
+ public int RowNumber { get; set; }
+
+ public Guid Id { get; set; }
+
+ public string Message { get; set; } = string.Empty;
+}
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
index 8bd2957..8fbb65f 100644
--- 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
@@ -38,7 +38,9 @@ public interface IGroupAppService : IApplicationService
Task DeleteAsync(string id);
///
- /// 按列表相同筛选条件导出组织为 PDF(上限 5000 条)
+ /// 按列表相同筛选条件全量导出组织(Region)为 PDF(不分页;与 相同筛选;单次最多 5000 条)
///
+ /// Keyword、PartnerId、State、Sorting;分页字段忽略
+ /// application/pdf
Task ExportPdfAsync(GroupGetListInputVo input);
}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILocationAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILocationAppService.cs
index 4474374..7dd222b 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILocationAppService.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILocationAppService.cs
@@ -1,5 +1,6 @@
using FoodLabeling.Application.Contracts.Dtos.Location;
using FoodLabeling.Application.Contracts.Dtos.Common;
+using Microsoft.AspNetCore.Mvc;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
@@ -34,5 +35,60 @@ public interface ILocationAppService : IApplicationService
///
/// 门店Id
Task DeleteAsync(Guid id);
+
+ ///
+ /// 下载 Location Manager 批量导入模板(读取服务器 batchImportOfFiles 目录下 xlsx)
+ ///
+ Task DownloadLocationImportTemplateAsync();
+
+ ///
+ /// 按列表筛选条件全量导出门店为 Excel(与列表相同过滤与排序,不分页、不限条数)
+ ///
+ Task ExportLocationsExcelAsync(LocationGetListInputVo input);
+
+ ///
+ /// 批量导入门店(Excel,multipart/form-data 字段 file)
+ ///
+ Task ImportLocationsBatchAsync(LocationBatchImportInputVo input);
+
+ ///
+ /// 批量编辑门店(网格保存全部,JSON 一次提交多行)
+ ///
+ ///
+ /// 每行通过 id 定位门店,字段与单条 PUT /location/{id} 一致;id 为 的项忽略。
+ ///
+ /// 示例请求:
+ /// ```json
+ /// {
+ /// "items": [
+ /// {
+ /// "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ /// "partner": "MedVantage Cafe Group",
+ /// "groupName": "NC Region",
+ /// "locationName": "UNCC store",
+ /// "street": "222 School House Lane",
+ /// "city": "Charlotte",
+ /// "stateCode": "NC",
+ /// "country": "USA",
+ /// "zipCode": "29889",
+ /// "phone": "2123456789",
+ /// "email": "nc@example.com",
+ /// "latitude": 35.3,
+ /// "longitude": -80.7,
+ /// "state": true
+ /// }
+ /// ]
+ /// }
+ /// ```
+ ///
+ /// 参数说明:
+ /// - items: 编辑行数组;单行失败不影响其它行提交结果汇总
+ ///
+ /// 批量编辑请求体
+ /// 成功数、失败数及失败明细
+ /// 全部或部分行处理完成,见返回体中的计数与 errors
+ /// 整单校验失败(如超过单次条数上限、items 为空)
+ /// 服务器错误
+ Task UpdateLocationsBulkAsync(LocationBulkUpdateInputVo 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
index 47ee7e1..096be2c 100644
--- 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
@@ -73,7 +73,7 @@ public interface IPartnerAppService : IApplicationService
Task DeleteAsync(string id);
///
- /// 按当前列表筛选条件批量导出合作伙伴为 PDF(不分页,上限 5000 条)
+ /// 按当前列表筛选条件批量导出合作伙伴为 PDF(Account Management「Company」页签;不分页,上限 5000 条)
///
/// 与列表相同的 Keyword、State;分页字段忽略
/// PDF 文件流
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs
index d126552..1ad120a 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs
@@ -1,5 +1,6 @@
using FoodLabeling.Application.Contracts.Dtos.Common;
using FoodLabeling.Application.Contracts.Dtos.Product;
+using Microsoft.AspNetCore.Mvc;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
@@ -42,5 +43,25 @@ public interface IProductAppService : IApplicationService
/// 删除产品(逻辑删除)
///
Task DeleteAsync(string id);
+
+ ///
+ /// 下载 Product 批量导入模板(服务器 TemplateDirectory 下 xlsx)
+ ///
+ Task DownloadProductImportTemplateAsync();
+
+ ///
+ /// 按列表筛选条件全量导出产品为 Excel(与列表相同过滤;不分页)
+ ///
+ Task ExportProductsExcelAsync(ProductGetListInputVo input);
+
+ ///
+ /// 批量导入产品(Excel,multipart/form-data 字段 file)
+ ///
+ Task ImportProductsBatchAsync(ProductBatchImportInputVo input);
+
+ ///
+ /// 批量编辑产品(JSON 一次提交多行,与单条 PUT 字段一致)
+ ///
+ Task UpdateProductsBulkAsync(ProductBulkUpdateInputVo 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
index e8ced4a..d4536e0 100644
--- 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
@@ -22,6 +22,11 @@ public interface IReportsAppService : IApplicationService
Task ExportPrintLogPdfAsync(ReportsPrintLogGetListInputVo input);
///
+ /// Print Log 全量导出 Excel(筛选与列表一致;排序与列表 PrintedAt 规则一致;最多 5000 条)
+ ///
+ Task ExportPrintLogExcelAsync(ReportsPrintLogGetListInputVo input);
+
+ ///
/// 根据历史任务重打(与 App 入参一致);admin 可重打任意用户任务,否则仅本人任务。
///
Task ReprintPrintLogAsync(UsAppLabelReprintInputVo input);
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs
index 72f4dfe..d54e472 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs
@@ -1,5 +1,6 @@
using FoodLabeling.Application.Contracts.Dtos.Common;
using FoodLabeling.Application.Contracts.Dtos.TeamMember;
+using Microsoft.AspNetCore.Mvc;
namespace FoodLabeling.Application.Contracts.IServices;
@@ -14,5 +15,24 @@ public interface ITeamMemberAppService
Task UpdateAsync(Guid id, TeamMemberUpdateInputVo input);
Task DeleteAsync(Guid id);
-}
+ ///
+ /// 下载 Team Member 批量导入模板(服务器 batchImportOfFiles 目录下 xlsx)
+ ///
+ Task DownloadTeamMemberImportTemplateAsync();
+
+ ///
+ /// 按列表筛选条件全量导出成员为 PDF(与列表相同过滤;不分页、不限条数)
+ ///
+ Task ExportTeamMembersPdfAsync(TeamMemberGetListInputVo input);
+
+ ///
+ /// 批量导入成员(Excel,multipart/form-data 字段 file)
+ ///
+ Task ImportTeamMembersBatchAsync(TeamMemberBatchImportInputVo input);
+
+ ///
+ /// 批量编辑成员(JSON 一次提交多行)
+ ///
+ Task UpdateTeamMembersBulkAsync(TeamMemberBulkUpdateInputVo input);
+}
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 44933dd..713f6ec 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
@@ -2,6 +2,7 @@
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs
index 282a042..2444faf 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs
@@ -1,5 +1,8 @@
using FoodLabeling.Application.Contracts;
+using FoodLabeling.Application.Options;
using FoodLabeling.Domain;
+using Microsoft.Extensions.DependencyInjection;
+using Volo.Abp.Modularity;
using Yi.Framework.Ddd.Application;
namespace FoodLabeling.Application;
@@ -14,5 +17,10 @@ namespace FoodLabeling.Application;
)]
public class FoodLabelingApplicationModule : AbpModule
{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ Configure(
+ context.Services.GetConfiguration().GetSection(FoodLabelingBatchImportOptions.SectionName));
+ }
}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationBatchExcelHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationBatchExcelHelper.cs
new file mode 100644
index 0000000..ece9923
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationBatchExcelHelper.cs
@@ -0,0 +1,359 @@
+using System.Globalization;
+using ClosedXML.Excel;
+using FoodLabeling.Application.Contracts.Dtos.Location;
+
+namespace FoodLabeling.Application.Helpers;
+
+///
+/// Location 批量导入/导出 Excel(列名与 Web「Location Manager」表头对齐,兼容中英文表头别名)
+///
+public static class LocationBatchExcelHelper
+{
+ /// 导出表头顺序(与模板一致)
+ public static readonly string[] ExportHeaders =
+ {
+ "Company", "Region", "Location ID", "Location Name", "Street", "City", "State", "Country",
+ "Zip Code", "Phone", "Email", "Latitude", "Longitude", "Active"
+ };
+
+ ///
+ /// 将门店列表写入 xlsx 内存流。
+ ///
+ public static MemoryStream BuildExportWorkbook(IReadOnlyList rows)
+ {
+ var ms = new MemoryStream();
+ using var wb = new XLWorkbook();
+ var ws = wb.AddWorksheet("Locations");
+ for (var i = 0; i < ExportHeaders.Length; i++)
+ {
+ ws.Cell(1, i + 1).Value = ExportHeaders[i];
+ ws.Cell(1, i + 1).Style.Font.Bold = true;
+ }
+
+ var r = 2;
+ foreach (var x in rows)
+ {
+ ws.Cell(r, 1).Value = x.Partner ?? string.Empty;
+ ws.Cell(r, 2).Value = x.GroupName ?? string.Empty;
+ ws.Cell(r, 3).Value = x.LocationCode ?? string.Empty;
+ ws.Cell(r, 4).Value = x.LocationName ?? string.Empty;
+ ws.Cell(r, 5).Value = x.Street ?? string.Empty;
+ ws.Cell(r, 6).Value = x.City ?? string.Empty;
+ ws.Cell(r, 7).Value = x.StateCode ?? string.Empty;
+ ws.Cell(r, 8).Value = x.Country ?? string.Empty;
+ ws.Cell(r, 9).Value = x.ZipCode ?? string.Empty;
+ ws.Cell(r, 10).Value = x.Phone ?? string.Empty;
+ ws.Cell(r, 11).Value = x.Email ?? string.Empty;
+ ws.Cell(r, 12).Value = x.Latitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
+ ws.Cell(r, 13).Value = x.Longitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
+ ws.Cell(r, 14).Value = x.State ? "TRUE" : "FALSE";
+ r++;
+ }
+
+ ws.Columns().AdjustToContents();
+ wb.SaveAs(ms);
+ ms.Position = 0;
+ return ms;
+ }
+
+ ///
+ /// 从上传的 Excel 解析为创建入参列表(行号从 2 起为数据行)。
+ ///
+ public static List<(int RowNumber, LocationCreateInputVo Input)> ParseImportWorkbook(Stream stream,
+ int maxRows,
+ out List parseErrors)
+ {
+ parseErrors = new List();
+ var result = new List<(int, LocationCreateInputVo)>();
+
+ using var wb = new XLWorkbook(stream);
+ var ws = wb.Worksheets.FirstOrDefault();
+ if (ws is null)
+ {
+ parseErrors.Add(new LocationBatchImportErrorDto
+ {
+ RowNumber = 0,
+ Message = "Excel 中无工作表"
+ });
+ return result;
+ }
+
+ var headerRow = ws.Row(1);
+ if (!headerRow.CellsUsed().Any())
+ {
+ parseErrors.Add(new LocationBatchImportErrorDto { RowNumber = 1, Message = "表头为空" });
+ return result;
+ }
+
+ var colMap = BuildHeaderColumnMap(headerRow);
+ if (!colMap.ContainsKey("locationcode"))
+ {
+ parseErrors.Add(new LocationBatchImportErrorDto
+ {
+ RowNumber = 1,
+ Message = "未找到「Location ID」列(或同义表头),请使用官方模板"
+ });
+ return result;
+ }
+
+ var lastRow = ws.LastRowUsed()?.RowNumber() ?? 1;
+ var dataRowCount = 0;
+ for (var rowNum = 2; rowNum <= lastRow; rowNum++)
+ {
+ if (dataRowCount >= maxRows)
+ {
+ parseErrors.Add(new LocationBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ Message = $"已超过单次导入上限 {maxRows} 行,后续行已忽略"
+ });
+ break;
+ }
+
+ var locCode = GetCellByField(colMap, ws, rowNum, "locationcode");
+ if (string.IsNullOrWhiteSpace(locCode) && IsRowEmpty(colMap, ws, rowNum))
+ {
+ continue;
+ }
+
+ dataRowCount++;
+ var errPrefix = $"第 {rowNum} 行";
+ try
+ {
+ var input = BuildCreateInputFromRow(colMap, ws, rowNum, out var rowErrs);
+ if (rowErrs.Count > 0)
+ {
+ foreach (var e in rowErrs)
+ {
+ parseErrors.Add(new LocationBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ LocationCode = locCode,
+ Message = $"{errPrefix}:{e}"
+ });
+ }
+
+ continue;
+ }
+
+ result.Add((rowNum, input!));
+ }
+ catch (Exception ex)
+ {
+ parseErrors.Add(new LocationBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ LocationCode = locCode,
+ Message = $"{errPrefix}:{ex.Message}"
+ });
+ }
+ }
+
+ return result;
+ }
+
+ private static Dictionary BuildHeaderColumnMap(IXLRow headerRow)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var cell in headerRow.CellsUsed())
+ {
+ var key = NormalizeHeaderKey(cell.GetString());
+ if (string.IsNullOrEmpty(key))
+ {
+ continue;
+ }
+
+ var field = MapHeaderToField(key);
+ if (field is null)
+ {
+ continue;
+ }
+
+ if (!map.ContainsKey(field))
+ {
+ map[field] = cell.Address.ColumnNumber;
+ }
+ }
+
+ return map;
+ }
+
+ private static string? MapHeaderToField(string normalizedHeader)
+ {
+ return normalizedHeader switch
+ {
+ "company" or "partner" or "合作伙伴" or "公司" => "partner",
+ "region" or "groupname" or "group" or "区域" or "组织" => "groupname",
+ "locationid" or "locationcode" or "门店编码" or "门店id" => "locationcode",
+ "locationname" or "门店名称" or "name" => "locationname",
+ "street" or "地址" => "street",
+ "city" or "城市" => "city",
+ "state" or "statecode" or "省州" => "statecode",
+ "country" or "国家" => "country",
+ "zipcode" or "zip" or "邮编" or "邮政编码" => "zipcode",
+ "phone" or "电话" or "手机" => "phone",
+ "email" or "邮箱" => "email",
+ "latitude" or "lat" or "纬度" => "latitude",
+ "longitude" or "lng" or "lon" or "经度" => "longitude",
+ "active" or "启用" or "状态" or "isactive" => "active",
+ _ => null
+ };
+ }
+
+ private static string NormalizeHeaderKey(string raw)
+ {
+ var s = raw.Trim();
+ if (s.Length > 0 && s[0] == '\uFEFF')
+ {
+ s = s.TrimStart('\uFEFF');
+ }
+
+ s = s.Trim();
+ return string.Concat(s.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
+ }
+
+ private static bool IsRowEmpty(Dictionary colMap, IXLWorksheet ws, int rowNum)
+ {
+ foreach (var col in colMap.Values)
+ {
+ var t = ws.Cell(rowNum, col).GetString().Trim();
+ if (!string.IsNullOrEmpty(t))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static string GetCellByField(Dictionary colMap, IXLWorksheet ws, int row, string field)
+ {
+ if (!colMap.TryGetValue(field, out var col))
+ {
+ return string.Empty;
+ }
+
+ return ws.Cell(row, col).GetString().Trim();
+ }
+
+ private static LocationCreateInputVo? BuildCreateInputFromRow(Dictionary colMap, IXLWorksheet ws,
+ int rowNum, out List errors)
+ {
+ errors = new List();
+ var partner = GetCellByField(colMap, ws, rowNum, "partner");
+ var groupName = GetCellByField(colMap, ws, rowNum, "groupname");
+ var locationCode = GetCellByField(colMap, ws, rowNum, "locationcode");
+ var locationName = GetCellByField(colMap, ws, rowNum, "locationname");
+ var street = GetCellByField(colMap, ws, rowNum, "street");
+ var city = GetCellByField(colMap, ws, rowNum, "city");
+ var stateCode = GetCellByField(colMap, ws, rowNum, "statecode");
+ var country = GetCellByField(colMap, ws, rowNum, "country");
+ var zipCode = GetCellByField(colMap, ws, rowNum, "zipcode");
+ var phone = GetCellByField(colMap, ws, rowNum, "phone");
+ var email = GetCellByField(colMap, ws, rowNum, "email");
+ var latStr = GetCellByField(colMap, ws, rowNum, "latitude");
+ var lngStr = GetCellByField(colMap, ws, rowNum, "longitude");
+ var activeStr = GetCellByField(colMap, ws, rowNum, "active");
+
+ if (string.IsNullOrWhiteSpace(locationCode))
+ {
+ errors.Add("Location ID 不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(locationName))
+ {
+ errors.Add("Location Name 不能为空");
+ }
+
+ decimal? lat = null;
+ if (!string.IsNullOrWhiteSpace(latStr))
+ {
+ if (!decimal.TryParse(latStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var latVal))
+ {
+ errors.Add("Latitude 格式不正确");
+ }
+ else
+ {
+ lat = latVal;
+ }
+ }
+
+ decimal? lng = null;
+ if (!string.IsNullOrWhiteSpace(lngStr))
+ {
+ if (!decimal.TryParse(lngStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var lngVal))
+ {
+ errors.Add("Longitude 格式不正确");
+ }
+ else
+ {
+ lng = lngVal;
+ }
+ }
+
+ if (errors.Count > 0)
+ {
+ return null;
+ }
+
+ var state = ParseBool(activeStr, defaultValue: true);
+ return new LocationCreateInputVo
+ {
+ Partner = NullIfEmpty(partner),
+ GroupName = NullIfEmpty(groupName),
+ LocationCode = locationCode,
+ LocationName = locationName,
+ Street = NullIfEmpty(street),
+ City = NullIfEmpty(city),
+ StateCode = NullIfEmpty(stateCode),
+ Country = NullIfEmpty(country),
+ ZipCode = NullIfEmpty(zipCode),
+ Phone = NullIfEmpty(phone),
+ Email = NullIfEmpty(email),
+ Latitude = lat,
+ Longitude = lng,
+ State = state
+ };
+ }
+
+ private static string? NullIfEmpty(string s)
+ {
+ var t = s.Trim();
+ return string.IsNullOrEmpty(t) ? null : t;
+ }
+
+ private static bool ParseBool(string? raw, bool defaultValue)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return defaultValue;
+ }
+
+ var s = raw.Trim();
+ if (bool.TryParse(s, out var b))
+ {
+ return b;
+ }
+
+ if (int.TryParse(s, out var n))
+ {
+ return n != 0;
+ }
+
+ if (string.Equals(s, "是", StringComparison.Ordinal) ||
+ string.Equals(s, "Y", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(s, "Yes", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (string.Equals(s, "否", StringComparison.Ordinal) ||
+ string.Equals(s, "N", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(s, "No", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return defaultValue;
+ }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ProductBatchExcelHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ProductBatchExcelHelper.cs
new file mode 100644
index 0000000..ca552af
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ProductBatchExcelHelper.cs
@@ -0,0 +1,245 @@
+using ClosedXML.Excel;
+using FoodLabeling.Application.Contracts.Dtos.Product;
+
+namespace FoodLabeling.Application.Helpers;
+
+///
+/// Product 批量导入/导出 Excel(列与「Product-Manager-批量导入模板」一致)
+///
+public static class ProductBatchExcelHelper
+{
+ /// 导出表头顺序(与模板一致)
+ public static readonly string[] ExportHeaders =
+ {
+ "Location", "Product Category", "Product", "Product Code"
+ };
+
+ /// 导出数据行
+ public sealed record ExportRow(string LocationDisplay, string CategoryName, string ProductName, string ProductCode);
+
+ ///
+ /// 将产品导出数据写入 xlsx 内存流(工作表名 Products)。
+ ///
+ public static MemoryStream BuildExportWorkbook(IReadOnlyList rows)
+ {
+ var ms = new MemoryStream();
+ using var wb = new XLWorkbook();
+ var ws = wb.AddWorksheet("Products");
+ for (var i = 0; i < ExportHeaders.Length; i++)
+ {
+ ws.Cell(1, i + 1).Value = ExportHeaders[i];
+ ws.Cell(1, i + 1).Style.Font.Bold = true;
+ }
+
+ var r = 2;
+ foreach (var x in rows)
+ {
+ ws.Cell(r, 1).Value = x.LocationDisplay ?? string.Empty;
+ ws.Cell(r, 2).Value = x.CategoryName ?? string.Empty;
+ ws.Cell(r, 3).Value = x.ProductName ?? string.Empty;
+ ws.Cell(r, 4).Value = x.ProductCode ?? string.Empty;
+ r++;
+ }
+
+ ws.Columns().AdjustToContents();
+ wb.SaveAs(ms);
+ ms.Position = 0;
+ return ms;
+ }
+
+ ///
+ /// 从上传的 Excel 解析为原始行(行号从 2 起为数据行)。
+ ///
+ public static List<(int RowNumber, string LocationCell, string CategoryName, string ProductName, string ProductCode)>
+ ParseImportWorkbook(Stream stream, int maxRows, out List parseErrors)
+ {
+ parseErrors = new List();
+ var result = new List<(int, string, string, string, string)>();
+
+ using var wb = new XLWorkbook(stream);
+ var ws = wb.Worksheets.FirstOrDefault(w =>
+ string.Equals(w.Name, "Products", StringComparison.OrdinalIgnoreCase))
+ ?? wb.Worksheets.FirstOrDefault();
+ if (ws is null)
+ {
+ parseErrors.Add(new ProductBatchImportErrorDto { RowNumber = 0, Message = "Excel 中无工作表" });
+ return result;
+ }
+
+ var headerRow = ws.Row(1);
+ if (!headerRow.CellsUsed().Any())
+ {
+ parseErrors.Add(new ProductBatchImportErrorDto { RowNumber = 1, Message = "表头为空" });
+ return result;
+ }
+
+ var colMap = BuildHeaderColumnMap(headerRow);
+ if (!colMap.ContainsKey("product") || !colMap.ContainsKey("productcategory"))
+ {
+ parseErrors.Add(new ProductBatchImportErrorDto
+ {
+ RowNumber = 1,
+ Message = "未找到「Product」与「Product Category」列(或同义表头),请使用官方模板"
+ });
+ return result;
+ }
+
+ var lastRow = ws.LastRowUsed()?.RowNumber() ?? 1;
+ var dataRowCount = 0;
+ for (var rowNum = 2; rowNum <= lastRow; rowNum++)
+ {
+ if (dataRowCount >= maxRows)
+ {
+ parseErrors.Add(new ProductBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ Message = $"已超过单次导入上限 {maxRows} 行,后续行已忽略"
+ });
+ break;
+ }
+
+ var productName = GetCellText(colMap, ws, rowNum, "product");
+ var categoryName = GetCellText(colMap, ws, rowNum, "productcategory");
+ var locationCell = colMap.ContainsKey("location")
+ ? GetCellText(colMap, ws, rowNum, "location")
+ : string.Empty;
+ var productCode = colMap.ContainsKey("productcode")
+ ? GetCellText(colMap, ws, rowNum, "productcode")
+ : string.Empty;
+
+ if (string.IsNullOrWhiteSpace(productName) && string.IsNullOrWhiteSpace(categoryName) &&
+ IsRowEmpty(colMap, ws, rowNum))
+ {
+ continue;
+ }
+
+ dataRowCount++;
+ var errPrefix = $"第 {rowNum} 行";
+ var rowErrs = new List();
+ if (string.IsNullOrWhiteSpace(productName))
+ {
+ rowErrs.Add("Product 不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(categoryName))
+ {
+ rowErrs.Add("Product Category 不能为空");
+ }
+
+ if (rowErrs.Count > 0)
+ {
+ foreach (var e in rowErrs)
+ {
+ parseErrors.Add(new ProductBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ ProductName = productName,
+ Message = $"{errPrefix}:{e}"
+ });
+ }
+
+ continue;
+ }
+
+ result.Add((rowNum, locationCell, categoryName.Trim(), productName.Trim(), productCode.Trim()));
+ }
+
+ return result;
+ }
+
+ private static Dictionary BuildHeaderColumnMap(IXLRow headerRow)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var cell in headerRow.CellsUsed())
+ {
+ var key = NormalizeHeaderKey(cell.GetString());
+ if (string.IsNullOrEmpty(key))
+ {
+ continue;
+ }
+
+ var field = MapHeaderToField(key);
+ if (field is null)
+ {
+ continue;
+ }
+
+ if (!map.ContainsKey(field))
+ {
+ map[field] = cell.Address.ColumnNumber;
+ }
+ }
+
+ return map;
+ }
+
+ private static string? MapHeaderToField(string normalizedHeader)
+ {
+ return normalizedHeader switch
+ {
+ "location" or "locations" or "门店" or "分配门店" => "location",
+ "productcategory" or "category" or "产品分类" or "分类" => "productcategory",
+ "product" or "productname" or "产品" or "产品名称" => "product",
+ "productcode" or "code" or "产品编码" or "编码" => "productcode",
+ _ => null
+ };
+ }
+
+ private static string NormalizeHeaderKey(string raw)
+ {
+ var s = raw.Trim();
+ if (s.Length > 0 && s[0] == '\uFEFF')
+ {
+ s = s.TrimStart('\uFEFF');
+ }
+
+ s = s.Trim().TrimStart('*');
+ return string.Concat(s.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
+ }
+
+ private static bool IsRowEmpty(Dictionary colMap, IXLWorksheet ws, int rowNum)
+ {
+ foreach (var col in colMap.Values)
+ {
+ var t = GetCellRaw(ws, rowNum, col);
+ if (!string.IsNullOrEmpty(t))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static string GetCellText(Dictionary colMap, IXLWorksheet ws, int row, string field)
+ {
+ if (!colMap.TryGetValue(field, out var col))
+ {
+ return string.Empty;
+ }
+
+ return GetCellRaw(ws, row, col).Trim();
+ }
+
+ private static string GetCellRaw(IXLWorksheet ws, int row, int col)
+ {
+ var c = ws.Cell(row, col);
+ if (c.IsEmpty())
+ {
+ return string.Empty;
+ }
+
+ var s = c.GetString().Trim();
+ if (!string.IsNullOrEmpty(s))
+ {
+ return s;
+ }
+
+ if (c.Value.IsNumber)
+ {
+ return c.GetFormattedString().Trim();
+ }
+
+ return c.Value.ToString()?.Trim() ?? string.Empty;
+ }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExcelHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExcelHelper.cs
new file mode 100644
index 0000000..fbdae4e
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExcelHelper.cs
@@ -0,0 +1,51 @@
+using ClosedXML.Excel;
+using FoodLabeling.Application.Contracts.Dtos.Reports;
+
+namespace FoodLabeling.Application.Helpers;
+
+///
+/// Reports — Print Log 全量导出 Excel(列与 Web Print Log 表头对齐)
+///
+public static class ReportsPrintLogExcelHelper
+{
+ /// 导出表头(与 UI 一致)
+ public static readonly string[] Headers =
+ {
+ "Label ID", "Product Name", "Category", "Template", "Printed At", "Printed By", "Location", "Expiry Date"
+ };
+
+ ///
+ /// 将 Print Log 行写入 xlsx 内存流(工作表名 Print Log)。
+ ///
+ public static MemoryStream BuildWorkbook(IReadOnlyList rows)
+ {
+ var ms = new MemoryStream();
+ using var wb = new XLWorkbook();
+ var ws = wb.AddWorksheet("Print Log");
+ for (var i = 0; i < Headers.Length; i++)
+ {
+ ws.Cell(1, i + 1).Value = Headers[i];
+ ws.Cell(1, i + 1).Style.Font.Bold = true;
+ }
+
+ var r = 2;
+ foreach (var x in rows)
+ {
+ ws.Cell(r, 1).Value = x.LabelCode ?? string.Empty;
+ ws.Cell(r, 2).Value = x.ProductName ?? string.Empty;
+ ws.Cell(r, 3).Value = x.CategoryName ?? string.Empty;
+ ws.Cell(r, 4).Value = x.TemplateText ?? string.Empty;
+ ws.Cell(r, 5).Value = x.PrintedAt;
+ ws.Cell(r, 5).Style.DateFormat.Format = "yyyy-mm-dd hh:mm:ss";
+ ws.Cell(r, 6).Value = x.PrintedByName ?? string.Empty;
+ ws.Cell(r, 7).Value = x.LocationText ?? string.Empty;
+ ws.Cell(r, 8).Value = x.ExpiryDateText ?? string.Empty;
+ r++;
+ }
+
+ ws.Columns().AdjustToContents();
+ wb.SaveAs(ms);
+ ms.Position = 0;
+ return ms;
+ }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberBatchExcelHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberBatchExcelHelper.cs
new file mode 100644
index 0000000..e2911cf
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberBatchExcelHelper.cs
@@ -0,0 +1,369 @@
+using System.Globalization;
+using ClosedXML.Excel;
+using FoodLabeling.Application.Contracts.Dtos.TeamMember;
+
+namespace FoodLabeling.Application.Helpers;
+
+///
+/// Team Member 批量导入 Excel(列名与 Account Management 表格对齐,兼容常见别名)
+///
+public static class TeamMemberBatchExcelHelper
+{
+ ///
+ /// 从上传的 Excel 解析为创建入参列表(行号从 2 起为数据行)。
+ ///
+ /// xlsx 流
+ /// 最多数据行
+ /// 角色名(忽略大小写、去空白)到角色 Id
+ /// 未填 Password 列时使用
+ /// 表头或解析错误
+ public static List<(int RowNumber, TeamMemberCreateInputVo Input)> ParseImportWorkbook(
+ Stream stream,
+ int maxRows,
+ IReadOnlyDictionary roleNameToId,
+ string defaultPassword,
+ out List parseErrors)
+ {
+ parseErrors = new List();
+ var result = new List<(int, TeamMemberCreateInputVo)>();
+
+ if (string.IsNullOrWhiteSpace(defaultPassword))
+ {
+ parseErrors.Add(new TeamMemberBatchImportErrorDto
+ {
+ RowNumber = 0,
+ Message = "未配置默认导入密码 FoodLabeling:BatchImport:TeamMemberImportDefaultPassword"
+ });
+ return result;
+ }
+
+ using var wb = new XLWorkbook(stream);
+ var ws = wb.Worksheets.FirstOrDefault();
+ if (ws is null)
+ {
+ parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = 0, Message = "Excel 中无工作表" });
+ return result;
+ }
+
+ var headerRow = ws.Row(1);
+ if (!headerRow.CellsUsed().Any())
+ {
+ parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = 1, Message = "表头为空" });
+ return result;
+ }
+
+ var colMap = BuildHeaderColumnMap(headerRow);
+ if (!colMap.ContainsKey("fullname") || !colMap.ContainsKey("email"))
+ {
+ parseErrors.Add(new TeamMemberBatchImportErrorDto
+ {
+ RowNumber = 1,
+ Message = "未找到「Name」与「Email」列(或同义表头),请使用官方模板"
+ });
+ return result;
+ }
+
+ var lastRow = ws.LastRowUsed()?.RowNumber() ?? 1;
+ var dataRowCount = 0;
+ for (var rowNum = 2; rowNum <= lastRow; rowNum++)
+ {
+ if (dataRowCount >= maxRows)
+ {
+ parseErrors.Add(new TeamMemberBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ Message = $"已超过单次导入上限 {maxRows} 行,后续行已忽略"
+ });
+ break;
+ }
+
+ var fullName = GetCellByField(colMap, ws, rowNum, "fullname");
+ var email = GetCellByField(colMap, ws, rowNum, "email");
+ if (string.IsNullOrWhiteSpace(fullName) && string.IsNullOrWhiteSpace(email) && IsRowEmpty(colMap, ws, rowNum))
+ {
+ continue;
+ }
+
+ dataRowCount++;
+ var errPrefix = $"第 {rowNum} 行";
+ try
+ {
+ var input = BuildCreateInputFromRow(
+ colMap,
+ ws,
+ rowNum,
+ roleNameToId,
+ defaultPassword,
+ out var rowErrs,
+ out var userNameHint);
+ if (rowErrs.Count > 0)
+ {
+ foreach (var e in rowErrs)
+ {
+ parseErrors.Add(new TeamMemberBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ UserName = userNameHint,
+ Message = $"{errPrefix}:{e}"
+ });
+ }
+
+ continue;
+ }
+
+ result.Add((rowNum, input!));
+ }
+ catch (Exception ex)
+ {
+ parseErrors.Add(new TeamMemberBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ Message = $"{errPrefix}:{ex.Message}"
+ });
+ }
+ }
+
+ return result;
+ }
+
+ private static Dictionary BuildHeaderColumnMap(IXLRow headerRow)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var cell in headerRow.CellsUsed())
+ {
+ var key = NormalizeHeaderKey(cell.GetString());
+ if (string.IsNullOrEmpty(key))
+ {
+ continue;
+ }
+
+ var field = MapHeaderToField(key);
+ if (field is null)
+ {
+ continue;
+ }
+
+ if (!map.ContainsKey(field))
+ {
+ map[field] = cell.Address.ColumnNumber;
+ }
+ }
+
+ return map;
+ }
+
+ private static string? MapHeaderToField(string normalizedHeader)
+ {
+ return normalizedHeader switch
+ {
+ "name" or "fullname" or "姓名" or "成员姓名" => "fullname",
+ "email" or "邮箱" or "e-mail" => "email",
+ "username" or "login" or "userid" or "账号" or "用户名" => "username",
+ "password" or "pwd" or "密码" => "password",
+ "phone" or "mobile" or "电话" or "手机" => "phone",
+ "role" or "rolename" or "角色" => "rolename",
+ "assignedlocations" or "locations" or "location" or "分配门店" or "门店" => "locations",
+ "status" or "active" or "state" or "启用" => "status",
+ _ => null
+ };
+ }
+
+ private static string NormalizeHeaderKey(string raw)
+ {
+ var s = raw.Trim();
+ if (s.Length > 0 && s[0] == '\uFEFF')
+ {
+ s = s.TrimStart('\uFEFF');
+ }
+
+ s = s.Trim().TrimStart('*');
+ return string.Concat(s.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
+ }
+
+ private static bool IsRowEmpty(Dictionary colMap, IXLWorksheet ws, int rowNum)
+ {
+ foreach (var col in colMap.Values)
+ {
+ var t = ws.Cell(rowNum, col).GetString().Trim();
+ if (!string.IsNullOrEmpty(t))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static string GetCellByField(Dictionary colMap, IXLWorksheet ws, int row, string field)
+ {
+ if (!colMap.TryGetValue(field, out var col))
+ {
+ return string.Empty;
+ }
+
+ return ws.Cell(row, col).GetString().Trim();
+ }
+
+ private static TeamMemberCreateInputVo? BuildCreateInputFromRow(
+ Dictionary colMap,
+ IXLWorksheet ws,
+ int rowNum,
+ IReadOnlyDictionary roleNameToId,
+ string defaultPassword,
+ out List errors,
+ out string? userNameHint)
+ {
+ errors = new List();
+ userNameHint = null;
+
+ var fullName = GetCellByField(colMap, ws, rowNum, "fullname");
+ var email = GetCellByField(colMap, ws, rowNum, "email");
+ var userName = GetCellByField(colMap, ws, rowNum, "username");
+ var password = GetCellByField(colMap, ws, rowNum, "password");
+ var phoneStr = GetCellByField(colMap, ws, rowNum, "phone");
+ var roleName = GetCellByField(colMap, ws, rowNum, "rolename");
+ var locationsCell = GetCellByField(colMap, ws, rowNum, "locations");
+ var statusStr = GetCellByField(colMap, ws, rowNum, "status");
+
+ if (string.IsNullOrWhiteSpace(fullName))
+ {
+ errors.Add("Name 不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(email))
+ {
+ errors.Add("Email 不能为空");
+ }
+
+ var login = string.IsNullOrWhiteSpace(userName) ? email.Trim() : userName.Trim();
+ userNameHint = login;
+
+ if (string.IsNullOrWhiteSpace(login))
+ {
+ errors.Add("登录账号不能为空(可填 UserName 列,否则使用 Email)");
+ }
+
+ var pwd = string.IsNullOrWhiteSpace(password) ? defaultPassword : password.Trim();
+ if (string.IsNullOrWhiteSpace(pwd))
+ {
+ errors.Add("Password 不能为空且未配置默认密码");
+ }
+
+ long? phone = null;
+ if (!string.IsNullOrWhiteSpace(phoneStr))
+ {
+ if (!long.TryParse(RegexDigitsOnly(phoneStr), NumberStyles.Integer, CultureInfo.InvariantCulture,
+ out var p))
+ {
+ errors.Add("Phone 格式不正确(需为数字)");
+ }
+ else
+ {
+ phone = p;
+ }
+ }
+
+ Guid? roleIdResolved = null;
+ if (string.IsNullOrWhiteSpace(roleName))
+ {
+ errors.Add("Role 不能为空");
+ }
+ else if (!roleNameToId.TryGetValue(NormalizeRoleKey(roleName), out var rid))
+ {
+ errors.Add($"未找到角色「{roleName.Trim()}」,请与系统角色名称一致");
+ }
+ else
+ {
+ roleIdResolved = rid;
+ }
+
+ var locationTokens = SplitLocationTokens(locationsCell);
+ if (locationTokens.Count == 0)
+ {
+ errors.Add("Assigned Locations 不能为空(多个门店可用分号、竖线或换行分隔)");
+ }
+
+ if (errors.Count > 0)
+ {
+ return null;
+ }
+
+ var state = ParseBool(statusStr, defaultValue: true);
+ return new TeamMemberCreateInputVo
+ {
+ FullName = fullName.Trim(),
+ Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
+ UserName = login,
+ Password = pwd,
+ Phone = phone,
+ RoleId = roleIdResolved,
+ LocationIds = locationTokens,
+ State = state
+ };
+ }
+
+ public static string NormalizeRoleKey(string roleName)
+ {
+ return string.Concat(roleName.Trim().Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
+ }
+
+ ///
+ /// 拆分门店单元格为「待解析」片段(后续由服务层解析为 Location Id)。
+ ///
+ public static List SplitLocationTokens(string locationsCell)
+ {
+ if (string.IsNullOrWhiteSpace(locationsCell))
+ {
+ return new List();
+ }
+
+ var parts = locationsCell
+ .Split(new[] { ';', '|', '\n', '\r', ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(x => x.Trim())
+ .Where(x => !string.IsNullOrEmpty(x))
+ .ToList();
+ return parts;
+ }
+
+ private static string RegexDigitsOnly(string s)
+ {
+ return new string(s.Where(char.IsDigit).ToArray());
+ }
+
+ private static bool ParseBool(string? raw, bool defaultValue)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return defaultValue;
+ }
+
+ var s = raw.Trim();
+ if (bool.TryParse(s, out var b))
+ {
+ return b;
+ }
+
+ if (int.TryParse(s, out var n))
+ {
+ return n != 0;
+ }
+
+ if (string.Equals(s, "active", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(s, "是", StringComparison.Ordinal) ||
+ string.Equals(s, "Y", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(s, "Yes", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (string.Equals(s, "inactive", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(s, "否", StringComparison.Ordinal) ||
+ string.Equals(s, "N", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(s, "No", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return defaultValue;
+ }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Options/FoodLabelingBatchImportOptions.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Options/FoodLabelingBatchImportOptions.cs
new file mode 100644
index 0000000..c2a114c
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Options/FoodLabelingBatchImportOptions.cs
@@ -0,0 +1,50 @@
+namespace FoodLabeling.Application.Options;
+
+///
+/// 批量导入模板目录(服务器静态路径)等配置。
+///
+public class FoodLabelingBatchImportOptions
+{
+ public const string SectionName = "FoodLabeling:BatchImport";
+
+ ///
+ /// 模板文件所在目录(Linux 示例:/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles)
+ ///
+ public string TemplateDirectory { get; set; } =
+ "/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles";
+
+ ///
+ /// Location Manager 导入模板文件名(与服务器上已上传文件名一致)
+ ///
+ public string LocationTemplateFileName { get; set; } = "Location-Manager-批量导入模板.xlsx";
+
+ ///
+ /// Team Member 导入模板文件名(与服务器上已上传文件名一致)
+ ///
+ public string TeamMemberTemplateFileName { get; set; } = "Team-Member-批量导入模板.xlsx";
+
+ ///
+ /// Product(Menu Management)导入模板文件名
+ ///
+ public string ProductTemplateFileName { get; set; } = "Product-Manager-批量导入模板.xlsx";
+
+ ///
+ /// Team Member 批量导入时,Excel 未填写「Password」列则使用的默认初始密码
+ ///
+ public string TeamMemberImportDefaultPassword { get; set; } = "ChangeMe123!";
+
+ ///
+ /// 单次导入最多处理的数据行数(不含表头)
+ ///
+ public int MaxImportRows { get; set; } = 5000;
+
+ ///
+ /// 上传 Excel 最大体积(字节),默认 10MB
+ ///
+ public long MaxUploadBytes { get; set; } = 10 * 1024 * 1024;
+
+ ///
+ /// 单次「批量编辑」请求最多允许的条数(含空行过滤前的数组长度)
+ ///
+ public int MaxBulkUpdateItems { get; set; } = 500;
+}
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
index 36d7c75..ed77a1f 100644
--- 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
@@ -196,14 +196,14 @@ public class GroupAppService : ApplicationService, IGroupAppService
.Take(ExportPdfMaxRows)
.ToListAsync();
- var fileName = $"groups_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
+ var fileName = $"regions_{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.Header().Text("Regions").SemiBold().FontSize(18);
page.Content().PaddingTop(12).Table(table =>
{
table.ColumnsDefinition(c =>
@@ -217,8 +217,8 @@ public class GroupAppService : ApplicationService, IGroupAppService
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("Region Name");
+ table.Cell().Element(CellHeader).Text("Parent company");
table.Cell().Element(CellHeader).Text("Status");
table.Cell().Element(CellHeader).Text("Created");
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs
index f63753c..9c64e83 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs
@@ -1,13 +1,17 @@
+using System.IO;
using FoodLabeling.Application.Helpers;
using FoodLabeling.Application.Contracts.Dtos.Location;
+using FoodLabeling.Application.Contracts.Dtos.Common;
using FoodLabeling.Application.Contracts.IServices;
+using FoodLabeling.Application.Options;
using FoodLabeling.Domain.Entities;
-using FoodLabeling.Application.Contracts.Dtos.Common;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
+using Volo.Abp.Domain.Repositories;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace FoodLabeling.Application.Services;
@@ -18,10 +22,14 @@ namespace FoodLabeling.Application.Services;
public class LocationAppService : ApplicationService, ILocationAppService
{
private readonly ISqlSugarRepository _locationRepository;
+ private readonly IOptionsSnapshot _batchImportOptions;
- public LocationAppService(ISqlSugarRepository locationRepository)
+ public LocationAppService(
+ ISqlSugarRepository locationRepository,
+ IOptionsSnapshot batchImportOptions)
{
_locationRepository = locationRepository;
+ _batchImportOptions = batchImportOptions;
}
///
@@ -29,29 +37,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
{
RefAsync total = 0;
- var keyword = input.Keyword?.Trim();
- var partner = input.Partner?.Trim();
- var groupName = input.GroupName?.Trim();
-
- var query = _locationRepository._DbQueryable
- .Where(x => x.IsDeleted == false)
- .WhereIF(!string.IsNullOrEmpty(partner), x => x.Partner == partner)
- .WhereIF(!string.IsNullOrEmpty(groupName), x => x.GroupName == groupName)
- .WhereIF(input.State is not null, x => x.State == input.State)
- .WhereIF(!string.IsNullOrEmpty(keyword),
- x =>
- x.LocationCode.Contains(keyword!) ||
- x.LocationName.Contains(keyword!) ||
- (x.Street != null && x.Street.Contains(keyword!)) ||
- (x.City != null && x.City.Contains(keyword!)) ||
- (x.StateCode != null && x.StateCode.Contains(keyword!)) ||
- (x.Country != null && x.Country.Contains(keyword!)) ||
- (x.ZipCode != null && x.ZipCode.Contains(keyword!)) ||
- (x.Phone != null && x.Phone.Contains(keyword!)) ||
- (x.Email != null && x.Email.Contains(keyword!))
- );
-
- // 先按排序字段走(如前端传入),否则默认按创建时间倒序
+ var query = BuildFilteredQuery(input);
if (!string.IsNullOrWhiteSpace(input.Sorting))
{
query = query.OrderBy(input.Sorting);
@@ -63,34 +49,17 @@ public class LocationAppService : ApplicationService, ILocationAppService
var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
- var items = entities.Select(x => new LocationGetListOutputDto
- {
- Id = x.Id,
- Partner = x.Partner,
- GroupName = x.GroupName,
- LocationCode = x.LocationCode,
- LocationName = x.LocationName,
- Street = x.Street,
- City = x.City,
- StateCode = x.StateCode,
- Country = x.Country,
- ZipCode = x.ZipCode,
- Phone = x.Phone,
- Email = x.Email,
- Latitude = x.Latitude,
- Longitude = x.Longitude,
- State = x.State
- }).ToList();
+ var items = entities.Select(ToListDto).ToList();
var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount;
var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
- var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
+ var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total.Value / (double)pageSize);
return new PagedResultWithPageDto
{
PageIndex = pageIndex,
PageSize = pageSize,
- TotalCount = total,
+ TotalCount = total.Value,
TotalPages = totalPages,
Items = items
};
@@ -137,24 +106,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
await _locationRepository.InsertAsync(entity);
- return new LocationGetListOutputDto
- {
- Id = entity.Id,
- Partner = entity.Partner,
- GroupName = entity.GroupName,
- LocationCode = entity.LocationCode,
- LocationName = entity.LocationName,
- Street = entity.Street,
- City = entity.City,
- StateCode = entity.StateCode,
- Country = entity.Country,
- ZipCode = entity.ZipCode,
- Phone = entity.Phone,
- Email = entity.Email,
- Latitude = entity.Latitude,
- Longitude = entity.Longitude,
- State = entity.State
- };
+ return ToListDto(entity);
}
///
@@ -172,7 +124,6 @@ public class LocationAppService : ApplicationService, ILocationAppService
throw new UserFriendlyException("Location Name不能为空");
}
- // LocationCode 默认不允许修改:业务编码需要保持唯一且稳定(如需变更,应走“新建+迁移”方案)
entity.Partner = input.Partner?.Trim();
entity.GroupName = input.GroupName?.Trim();
entity.LocationName = locationName;
@@ -189,24 +140,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
await _locationRepository.UpdateAsync(entity);
- return new LocationGetListOutputDto
- {
- Id = entity.Id,
- Partner = entity.Partner,
- GroupName = entity.GroupName,
- LocationCode = entity.LocationCode,
- LocationName = entity.LocationName,
- Street = entity.Street,
- City = entity.City,
- StateCode = entity.StateCode,
- Country = entity.Country,
- ZipCode = entity.ZipCode,
- Phone = entity.Phone,
- Email = entity.Email,
- Latitude = entity.Latitude,
- Longitude = entity.Longitude,
- State = entity.State
- };
+ return ToListDto(entity);
}
///
@@ -221,5 +155,219 @@ public class LocationAppService : ApplicationService, ILocationAppService
entity.IsDeleted = true;
await _locationRepository.UpdateAsync(entity);
}
-}
+ ///
+ public Task DownloadLocationImportTemplateAsync()
+ {
+ var opt = _batchImportOptions.Value;
+ var dir = opt.TemplateDirectory?.Trim();
+ if (string.IsNullOrWhiteSpace(dir))
+ {
+ throw new UserFriendlyException("未配置批量导入模板目录 FoodLabeling:BatchImport:TemplateDirectory");
+ }
+
+ var fileName = opt.LocationTemplateFileName?.Trim();
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ throw new UserFriendlyException("未配置模板文件名 FoodLabeling:BatchImport:LocationTemplateFileName");
+ }
+
+ var fullPath = Path.Combine(dir, fileName);
+ if (!File.Exists(fullPath))
+ {
+ throw new UserFriendlyException($"模板文件不存在:{fullPath}");
+ }
+
+ var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+ return Task.FromResult(new FileStreamResult(stream, contentType)
+ {
+ FileDownloadName = fileName
+ });
+ }
+
+ ///
+ public async Task ExportLocationsExcelAsync([FromQuery] LocationGetListInputVo input)
+ {
+ var exportFilter = new LocationGetListInputVo
+ {
+ Sorting = input.Sorting,
+ Keyword = input.Keyword,
+ Partner = input.Partner,
+ GroupName = input.GroupName,
+ State = input.State
+ };
+
+ var query = BuildFilteredQuery(exportFilter);
+ if (!string.IsNullOrWhiteSpace(exportFilter.Sorting))
+ {
+ query = query.OrderBy(exportFilter.Sorting);
+ }
+ else
+ {
+ query = query.OrderBy(x => x.CreationTime, OrderByType.Desc);
+ }
+
+ var entities = await query.ToListAsync();
+ var rows = entities.Select(ToListDto).ToList();
+
+ var ms = LocationBatchExcelHelper.BuildExportWorkbook(rows);
+ const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+ var downloadName = $"locations-export-{Clock.Now:yyyyMMdd-HHmmss}.xlsx";
+ return new FileStreamResult(ms, contentType) { FileDownloadName = downloadName };
+ }
+
+ ///
+ public async Task ImportLocationsBatchAsync([FromForm] LocationBatchImportInputVo input)
+ {
+ if (input?.File is null || input.File.Length == 0)
+ {
+ throw new UserFriendlyException("请上传 Excel 文件(form 字段名:file)");
+ }
+
+ var opt = _batchImportOptions.Value;
+ if (input.File.Length > opt.MaxUploadBytes)
+ {
+ throw new UserFriendlyException($"文件过大,最大允许 {opt.MaxUploadBytes / 1024 / 1024} MB");
+ }
+
+ var ext = Path.GetExtension(input.File.FileName)?.ToLowerInvariant();
+ if (ext != ".xlsx")
+ {
+ throw new UserFriendlyException("仅支持 .xlsx 格式的 Excel 文件");
+ }
+
+ await using var uploadStream = input.File.OpenReadStream();
+ var parseErrors = new List();
+ var rows = LocationBatchExcelHelper.ParseImportWorkbook(
+ uploadStream,
+ opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows,
+ out var headerErrors);
+ parseErrors.AddRange(headerErrors);
+
+ var result = new LocationBatchImportResultDto();
+ if (rows.Count == 0 && parseErrors.Count > 0)
+ {
+ result.Errors = parseErrors;
+ result.FailCount = parseErrors.Count;
+ return result;
+ }
+
+ foreach (var (rowNum, vo) in rows)
+ {
+ try
+ {
+ await CreateAsync(vo);
+ result.SuccessCount++;
+ }
+ catch (UserFriendlyException ex)
+ {
+ result.FailCount++;
+ result.Errors.Add(new LocationBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ LocationCode = vo.LocationCode,
+ Message = ex.Message
+ });
+ }
+ }
+
+ result.Errors.InsertRange(0, parseErrors);
+ return result;
+ }
+
+ ///
+ public async Task UpdateLocationsBulkAsync([FromBody] LocationBulkUpdateInputVo input)
+ {
+ if (input?.Items is null || input.Items.Count == 0)
+ {
+ throw new UserFriendlyException("请至少提交一条编辑数据(items 不能为空)");
+ }
+
+ var opt = _batchImportOptions.Value;
+ var maxItems = opt.MaxBulkUpdateItems <= 0 ? 500 : opt.MaxBulkUpdateItems;
+ if (input.Items.Count > maxItems)
+ {
+ throw new UserFriendlyException($"单次批量编辑最多允许 {maxItems} 条,请分批提交");
+ }
+
+ var effectiveCount = input.Items.Count(static x => x is not null && x.Id != Guid.Empty);
+ if (effectiveCount == 0)
+ {
+ throw new UserFriendlyException("没有有效的门店 Id(请为待保存行填写 id,空行请使用 id 为空 GUID 或从列表中移除)");
+ }
+
+ var result = new LocationBulkUpdateResultDto();
+ for (var i = 0; i < input.Items.Count; i++)
+ {
+ var item = input.Items[i];
+ if (item is null || item.Id == Guid.Empty)
+ {
+ continue;
+ }
+
+ try
+ {
+ await UpdateAsync(item.Id, item);
+ result.SuccessCount++;
+ }
+ catch (UserFriendlyException ex)
+ {
+ result.FailCount++;
+ result.Errors.Add(new LocationBulkUpdateErrorDto
+ {
+ RowNumber = i + 1,
+ Id = item.Id,
+ Message = ex.Message
+ });
+ }
+ }
+
+ return result;
+ }
+
+ private ISugarQueryable BuildFilteredQuery(LocationGetListInputVo input)
+ {
+ var keyword = input.Keyword?.Trim();
+ var partner = input.Partner?.Trim();
+ var groupName = input.GroupName?.Trim();
+
+ return _locationRepository._DbQueryable
+ .Where(x => x.IsDeleted == false)
+ .WhereIF(!string.IsNullOrEmpty(partner), x => x.Partner == partner)
+ .WhereIF(!string.IsNullOrEmpty(groupName), x => x.GroupName == groupName)
+ .WhereIF(input.State is not null, x => x.State == input.State)
+ .WhereIF(!string.IsNullOrEmpty(keyword),
+ x =>
+ x.LocationCode.Contains(keyword!) ||
+ x.LocationName.Contains(keyword!) ||
+ (x.Street != null && x.Street.Contains(keyword!)) ||
+ (x.City != null && x.City.Contains(keyword!)) ||
+ (x.StateCode != null && x.StateCode.Contains(keyword!)) ||
+ (x.Country != null && x.Country.Contains(keyword!)) ||
+ (x.ZipCode != null && x.ZipCode.Contains(keyword!)) ||
+ (x.Phone != null && x.Phone.Contains(keyword!)) ||
+ (x.Email != null && x.Email.Contains(keyword!))
+ );
+ }
+
+ private static LocationGetListOutputDto ToListDto(LocationAggregateRoot x) =>
+ new()
+ {
+ Id = x.Id,
+ Partner = x.Partner,
+ GroupName = x.GroupName,
+ LocationCode = x.LocationCode,
+ LocationName = x.LocationName,
+ Street = x.Street,
+ City = x.City,
+ StateCode = x.StateCode,
+ Country = x.Country,
+ ZipCode = x.ZipCode,
+ Phone = x.Phone,
+ Email = x.Email,
+ Latitude = x.Latitude,
+ Longitude = x.Longitude,
+ State = x.State
+ };
+}
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
index 321e705..dff0733 100644
--- 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
@@ -173,7 +173,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
}
var rows = await query.Take(ExportPdfMaxRows).ToListAsync();
- var fileName = $"partners_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
+ var fileName = $"companies_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
var document = Document.Create(container =>
{
@@ -181,7 +181,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
{
page.Margin(28);
page.DefaultTextStyle(x => x.FontSize(10));
- page.Header().Text("Partners").SemiBold().FontSize(18);
+ page.Header().Text("Companies").SemiBold().FontSize(18);
page.Content().PaddingTop(12).Table(table =>
{
table.ColumnsDefinition(c =>
@@ -196,7 +196,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
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("Company");
table.Cell().Element(CellHeader).Text("Contact");
table.Cell().Element(CellHeader).Text("Phone");
table.Cell().Element(CellHeader).Text("Status");
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
index dbe0058..7e2526b 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
@@ -1,9 +1,13 @@
-using FoodLabeling.Application.Helpers;
+using System.IO;
using FoodLabeling.Application.Contracts.Dtos.Common;
using FoodLabeling.Application.Contracts.Dtos.Product;
using FoodLabeling.Application.Contracts.IServices;
+using FoodLabeling.Application.Helpers;
+using FoodLabeling.Application.Options;
using FoodLabeling.Application.Services.DbModels;
using FoodLabeling.Domain.Entities;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Services;
@@ -20,34 +24,23 @@ public class ProductAppService : ApplicationService, IProductAppService
{
private readonly ISqlSugarDbContext _dbContext;
private readonly IGuidGenerator _guidGenerator;
+ private readonly IOptionsSnapshot _batchImportOptions;
- public ProductAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator)
+ public ProductAppService(
+ ISqlSugarDbContext dbContext,
+ IGuidGenerator guidGenerator,
+ IOptionsSnapshot batchImportOptions)
{
_dbContext = dbContext;
_guidGenerator = guidGenerator;
+ _batchImportOptions = batchImportOptions;
}
public async Task> GetListAsync(ProductGetListInputVo input)
{
RefAsync total = 0;
- var keyword = input.Keyword?.Trim();
-
- var query = _dbContext.SqlSugarClient
- .Queryable()
- .Where(x => !x.IsDeleted)
- .WhereIF(input.State != null, x => x.State == input.State);
-
- if (!string.IsNullOrWhiteSpace(keyword))
- {
- query = query
- .LeftJoin((p, c) => p.CategoryId == c.Id)
- .Where((p, c) =>
- p.ProductCode.Contains(keyword!) ||
- p.ProductName.Contains(keyword!) ||
- (c.CategoryName != null && c.CategoryName.Contains(keyword!)))
- .Select((p, c) => p);
- }
+ var query = BuildFilteredProductQuery(input);
if (!string.IsNullOrWhiteSpace(input.Sorting))
{
query = query.OrderBy(input.Sorting);
@@ -111,7 +104,7 @@ public class ProductAppService : ApplicationService, IProductAppService
};
}).ToList();
- return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
+ return BuildPagedResult(input.SkipCount, input.MaxResultCount, (int)total, items);
}
public async Task GetAsync(string id)
@@ -282,6 +275,369 @@ public class ProductAppService : ApplicationService, IProductAppService
await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
}
+ ///
+ public Task DownloadProductImportTemplateAsync()
+ {
+ var opt = _batchImportOptions.Value;
+ var dir = opt.TemplateDirectory?.Trim();
+ if (string.IsNullOrWhiteSpace(dir))
+ {
+ throw new UserFriendlyException("未配置批量导入模板目录 FoodLabeling:BatchImport:TemplateDirectory");
+ }
+
+ var fileName = opt.ProductTemplateFileName?.Trim();
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ throw new UserFriendlyException("未配置模板文件名 FoodLabeling:BatchImport:ProductTemplateFileName");
+ }
+
+ var fullPath = Path.Combine(dir, fileName);
+ if (!File.Exists(fullPath))
+ {
+ throw new UserFriendlyException($"模板文件不存在:{fullPath}");
+ }
+
+ var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+ return Task.FromResult(new FileStreamResult(stream, contentType)
+ {
+ FileDownloadName = fileName
+ });
+ }
+
+ ///
+ public async Task ExportProductsExcelAsync([FromQuery] ProductGetListInputVo input)
+ {
+ var exportFilter = new ProductGetListInputVo
+ {
+ Sorting = input.Sorting,
+ Keyword = input.Keyword,
+ State = input.State
+ };
+
+ var query = BuildFilteredProductQuery(exportFilter);
+ if (!string.IsNullOrWhiteSpace(exportFilter.Sorting))
+ {
+ query = query.OrderBy(exportFilter.Sorting);
+ }
+ else
+ {
+ query = query.OrderByDescending(x => x.ProductName);
+ }
+
+ var entities = await query.ToListAsync();
+ var exportRows = await BuildProductExcelExportRowsAsync(entities);
+ var ms = ProductBatchExcelHelper.BuildExportWorkbook(exportRows);
+ const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+ var downloadName = $"products-export-{Clock.Now:yyyyMMdd-HHmmss}.xlsx";
+ return new FileStreamResult(ms, contentType) { FileDownloadName = downloadName };
+ }
+
+ ///
+ public async Task ImportProductsBatchAsync(
+ [FromForm] ProductBatchImportInputVo input)
+ {
+ if (input?.File is null || input.File.Length == 0)
+ {
+ throw new UserFriendlyException("请上传 Excel 文件(form 字段名:file)");
+ }
+
+ var opt = _batchImportOptions.Value;
+ if (input.File.Length > opt.MaxUploadBytes)
+ {
+ throw new UserFriendlyException($"文件过大,最大允许 {opt.MaxUploadBytes / 1024 / 1024} MB");
+ }
+
+ var ext = Path.GetExtension(input.File.FileName)?.ToLowerInvariant();
+ if (ext != ".xlsx")
+ {
+ throw new UserFriendlyException("仅支持 .xlsx 格式的 Excel 文件");
+ }
+
+ await using var uploadStream = input.File.OpenReadStream();
+ var parseErrors = new List();
+ var rows = ProductBatchExcelHelper.ParseImportWorkbook(
+ uploadStream,
+ opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows,
+ out var headerErrors);
+ parseErrors.AddRange(headerErrors);
+
+ var result = new ProductBatchImportResultDto();
+ if (rows.Count == 0 && parseErrors.Count > 0)
+ {
+ result.Errors = parseErrors;
+ result.FailCount = parseErrors.Count;
+ return result;
+ }
+
+ foreach (var (rowNum, locCell, catName, prodName, codeStr) in rows)
+ {
+ try
+ {
+ var categoryId = await ResolveCategoryIdByNameAsync(catName);
+ List? locationIds = null;
+ if (!string.IsNullOrWhiteSpace(locCell))
+ {
+ locationIds = await ResolveLocationIdsFromImportDisplayCellAsync(locCell);
+ }
+
+ await CreateAsync(new ProductCreateInputVo
+ {
+ ProductName = prodName,
+ CategoryId = categoryId,
+ ProductCode = string.IsNullOrWhiteSpace(codeStr) ? null : codeStr,
+ State = true,
+ LocationIds = locationIds
+ });
+ result.SuccessCount++;
+ }
+ catch (UserFriendlyException ex)
+ {
+ result.FailCount++;
+ result.Errors.Add(new ProductBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ ProductName = prodName,
+ Message = ex.Message
+ });
+ }
+ }
+
+ result.Errors.InsertRange(0, parseErrors);
+ return result;
+ }
+
+ ///
+ public async Task UpdateProductsBulkAsync(
+ [FromBody] ProductBulkUpdateInputVo input)
+ {
+ if (input?.Items is null || input.Items.Count == 0)
+ {
+ throw new UserFriendlyException("请至少提交一条编辑数据(items 不能为空)");
+ }
+
+ var opt = _batchImportOptions.Value;
+ var maxItems = opt.MaxBulkUpdateItems <= 0 ? 500 : opt.MaxBulkUpdateItems;
+ if (input.Items.Count > maxItems)
+ {
+ throw new UserFriendlyException($"单次批量编辑最多允许 {maxItems} 条,请分批提交");
+ }
+
+ var effectiveCount = input.Items.Count(static x => x is not null && !string.IsNullOrWhiteSpace(x.Id));
+ if (effectiveCount == 0)
+ {
+ throw new UserFriendlyException("没有有效的产品 Id(请为待保存行填写 id)");
+ }
+
+ var result = new ProductBulkUpdateResultDto();
+ for (var i = 0; i < input.Items.Count; i++)
+ {
+ var item = input.Items[i];
+ if (item is null || string.IsNullOrWhiteSpace(item.Id))
+ {
+ continue;
+ }
+
+ try
+ {
+ await UpdateAsync(item.Id.Trim(), item);
+ result.SuccessCount++;
+ }
+ catch (UserFriendlyException ex)
+ {
+ result.FailCount++;
+ result.Errors.Add(new ProductBulkUpdateErrorDto
+ {
+ RowNumber = i + 1,
+ Id = item.Id.Trim(),
+ Message = ex.Message
+ });
+ }
+ }
+
+ return result;
+ }
+
+ private ISugarQueryable BuildFilteredProductQuery(ProductGetListInputVo input)
+ {
+ var keyword = input.Keyword?.Trim();
+
+ var query = _dbContext.SqlSugarClient
+ .Queryable()
+ .Where(x => !x.IsDeleted)
+ .WhereIF(input.State != null, x => x.State == input.State);
+
+ if (!string.IsNullOrWhiteSpace(keyword))
+ {
+ query = query
+ .LeftJoin((p, c) => p.CategoryId == c.Id)
+ .Where((p, c) =>
+ p.ProductCode.Contains(keyword!) ||
+ p.ProductName.Contains(keyword!) ||
+ (c.CategoryName != null && c.CategoryName.Contains(keyword!)))
+ .Select((p, c) => p);
+ }
+
+ return query;
+ }
+
+ private async Task> BuildProductExcelExportRowsAsync(
+ List entities)
+ {
+ if (entities.Count == 0)
+ {
+ return new List();
+ }
+
+ var categoryIds = entities
+ .Select(x => x.CategoryId)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x!.Trim())
+ .Distinct()
+ .ToList();
+
+ var categoryMap = new Dictionary();
+ if (categoryIds.Count > 0)
+ {
+ var cats = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted && categoryIds.Contains(x.Id))
+ .ToListAsync();
+ categoryMap = cats.ToDictionary(x => x.Id, x => x);
+ }
+
+ var productIds = entities.Select(x => x.Id).ToList();
+ var links = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => productIds.Contains(x.ProductId))
+ .ToListAsync();
+
+ var locIdSet = links
+ .Select(x => x.LocationId)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x.Trim())
+ .Distinct()
+ .ToList();
+
+ var locs = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted && locIdSet.Contains(x.Id.ToString()))
+ .Select(x => new { x.Id, x.LocationName })
+ .ToListAsync();
+
+ var locNameById = locs.ToDictionary(x => x.Id.ToString(), x => x.LocationName?.Trim() ?? string.Empty);
+
+ var locDisplayByProduct = links
+ .GroupBy(x => x.ProductId)
+ .ToDictionary(
+ g => g.Key,
+ g => string.Join(", ",
+ g.Select(y => y.LocationId.Trim())
+ .Distinct()
+ .Select(lid => locNameById.GetValueOrDefault(lid, lid))
+ .Where(s => !string.IsNullOrEmpty(s))
+ .Distinct()));
+
+ var rows = new List();
+ foreach (var e in entities)
+ {
+ var catName = "无";
+ if (!string.IsNullOrWhiteSpace(e.CategoryId) && categoryMap.TryGetValue(e.CategoryId.Trim(), out var c))
+ {
+ catName = string.IsNullOrWhiteSpace(c.CategoryName) ? "无" : c.CategoryName.Trim();
+ }
+
+ locDisplayByProduct.TryGetValue(e.Id, out var locDisp);
+ var locationDisplay = string.IsNullOrWhiteSpace(locDisp) ? string.Empty : locDisp;
+ rows.Add(new ProductBatchExcelHelper.ExportRow(
+ locationDisplay,
+ catName,
+ e.ProductName ?? string.Empty,
+ e.ProductCode ?? string.Empty));
+ }
+
+ return rows;
+ }
+
+ private async Task ResolveCategoryIdByNameAsync(string categoryName)
+ {
+ var n = categoryName.Trim();
+ if (string.IsNullOrWhiteSpace(n))
+ {
+ throw new UserFriendlyException("产品分类名称不能为空");
+ }
+
+ var lowered = n.ToLowerInvariant();
+ var matches = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted && x.CategoryName.ToLower() == lowered)
+ .ToListAsync();
+
+ if (matches.Count == 0)
+ {
+ throw new UserFriendlyException($"未找到产品分类「{n}」");
+ }
+
+ if (matches.Count > 1)
+ {
+ throw new UserFriendlyException($"产品分类「{n}」存在多条记录,请在系统中使用唯一名称");
+ }
+
+ return matches[0].Id;
+ }
+
+ private async Task> ResolveLocationIdsFromImportDisplayCellAsync(string cell)
+ {
+ var tokens = cell.Split(new[] { ',', ',', ';', ';', '|', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(x => x.Trim())
+ .Where(x => !string.IsNullOrEmpty(x))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+
+ var result = new List();
+ foreach (var t in tokens)
+ {
+ var idStr = await ResolveSingleLocationTokenToIdStringAsync(t);
+ result.Add(idStr);
+ }
+
+ return result.Distinct(StringComparer.Ordinal).ToList();
+ }
+
+ private async Task ResolveSingleLocationTokenToIdStringAsync(string token)
+ {
+ var t = token.Trim();
+ if (Guid.TryParse(t, out var gid))
+ {
+ var byIdList = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted && x.Id == gid)
+ .Take(1)
+ .ToListAsync();
+ var byId = byIdList.FirstOrDefault();
+ if (byId is null)
+ {
+ throw new UserFriendlyException($"未找到门店 Id:{t}");
+ }
+
+ return byId.Id.ToString();
+ }
+
+ var lowered = t.ToLowerInvariant();
+ var matches = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted &&
+ (x.LocationCode == t ||
+ x.LocationName.ToLower() == lowered))
+ .ToListAsync();
+
+ if (matches.Count == 0)
+ {
+ throw new UserFriendlyException($"未找到门店:{t}");
+ }
+
+ if (matches.Count > 1)
+ {
+ throw new UserFriendlyException($"门店「{t}」存在多条匹配,请使用 Location Code 或 Guid");
+ }
+
+ return matches[0].Id.ToString();
+ }
+
///
/// 生成未删除数据中不重复的 PRD_ 前缀产品编码。
///
@@ -376,4 +732,3 @@ public class ProductAppService : ApplicationService, IProductAppService
};
}
}
-
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
index 8640cec..b81aeba 100644
--- 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
@@ -1,7 +1,7 @@
using System.Globalization;
using System.Text.Json;
-using FoodLabeling.Application.Helpers;
using FoodLabeling.Application.Contracts.Dtos.Common;
+using FoodLabeling.Application.Helpers;
using FoodLabeling.Application.Contracts.Dtos.Reports;
using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
using FoodLabeling.Application.Contracts.IServices;
@@ -258,6 +258,89 @@ public class ReportsAppService : ApplicationService, IReportsAppService
}
///
+ public async Task ExportPrintLogExcelAsync([FromQuery] 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)
+ {
+ var emptyMs = ReportsPrintLogExcelHelper.BuildWorkbook(Array.Empty());
+ var emptyName = $"print-log_{Clock.Now:yyyyMMdd-HHmmss}.xlsx";
+ return new FileStreamResult(emptyMs, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+ { FileDownloadName = emptyName };
+ }
+
+ 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);
+
+ 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 count = await query.CountAsync();
+ if (count > ExportPdfMaxRows)
+ {
+ throw new UserFriendlyException($"导出数据超过上限 {ExportPdfMaxRows} 条,请缩小筛选范围");
+ }
+
+ var pageRows = await query.Take(ExportPdfMaxRows)
+ .Select((t, l, p, lc, pc, loc, tpl) => new PrintLogExportRow
+ {
+ Id = t.Id,
+ LabelCode = l.LabelCode,
+ ProductName = p.ProductName,
+ LabelCategoryName = lc.CategoryName,
+ ProductCategoryName = pc.CategoryName,
+ Width = tpl.Width,
+ Height = tpl.Height,
+ Unit = tpl.Unit,
+ TemplateName = tpl.TemplateName,
+ PrintInputJson = t.PrintInputJson,
+ PrintedAt = SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
+ CreatedBy = t.CreatedBy,
+ LocationId = t.LocationId,
+ LocName = loc.LocationName,
+ LocCode = loc.LocationCode
+ })
+ .ToListAsync();
+
+ var userMap = await LoadUserNameMapAsync(pageRows.Select(x => x.CreatedBy).Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x!).Distinct().ToList());
+
+ var items = pageRows.Select(x => MapPrintLogExportRowToListItem(x, userMap)).ToList();
+
+ var ms = ReportsPrintLogExcelHelper.BuildWorkbook(items);
+ var fileName = $"print-log_{Clock.Now:yyyyMMdd-HHmmss}.xlsx";
+ return new FileStreamResult(ms, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+ { FileDownloadName = fileName };
+ }
+
+ ///
public Task ReprintPrintLogAsync(UsAppLabelReprintInputVo input) =>
_usAppLabelingAppService.ReprintAsync(input);
@@ -774,4 +857,61 @@ public class ReportsAppService : ApplicationService, IReportsAppService
ms.Position = 0;
return new FileStreamResult(ms, "application/pdf") { FileDownloadName = fileName };
}
+
+ private sealed class PrintLogExportRow
+ {
+ public string Id { get; set; } = string.Empty;
+
+ public string? LabelCode { get; set; }
+
+ public string? ProductName { get; set; }
+
+ public string? LabelCategoryName { get; set; }
+
+ public string? ProductCategoryName { get; set; }
+
+ public decimal Width { get; set; }
+
+ public decimal Height { get; set; }
+
+ public string? Unit { get; set; }
+
+ public string? TemplateName { get; set; }
+
+ public string? PrintInputJson { get; set; }
+
+ public DateTime? PrintedAt { get; set; }
+
+ public string? CreatedBy { get; set; }
+
+ public string? LocationId { get; set; }
+
+ public string? LocName { get; set; }
+
+ public string? LocCode { get; set; }
+ }
+
+ private static ReportsPrintLogListItemDto MapPrintLogExportRowToListItem(PrintLogExportRow x,
+ Dictionary userMap)
+ {
+ 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)
+ };
+ }
}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs
index cc63bbd..9b9b25d 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs
@@ -1,9 +1,16 @@
-using FoodLabeling.Application.Helpers;
+using System.IO;
using FoodLabeling.Application.Contracts.Dtos.Common;
using FoodLabeling.Application.Contracts.Dtos.TeamMember;
using FoodLabeling.Application.Contracts.IServices;
+using FoodLabeling.Application.Helpers;
+using FoodLabeling.Application.Options;
using FoodLabeling.Application.Services.DbModels;
using FoodLabeling.Domain.Entities;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Services;
@@ -16,7 +23,7 @@ using Yi.Framework.SqlSugarCore.Abstractions;
namespace FoodLabeling.Application.Services;
///
-/// 成员(Team Member/Manager)服务,对外仅在 food-labeling-us 暴露
+/// 成员(Team Member)服务,对外仅在 food-labeling-us 暴露
///
public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
{
@@ -24,114 +31,39 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
private readonly UserManager _userManager;
private readonly ISqlSugarDbContext _dbContext;
private readonly IGuidGenerator _guidGenerator;
+ private readonly IOptionsSnapshot _batchImportOptions;
public TeamMemberAppService(
ISqlSugarRepository userRepository,
UserManager userManager,
ISqlSugarDbContext dbContext,
- IGuidGenerator guidGenerator)
+ IGuidGenerator guidGenerator,
+ IOptionsSnapshot batchImportOptions)
{
_userRepository = userRepository;
_userManager = userManager;
_dbContext = dbContext;
_guidGenerator = guidGenerator;
+ _batchImportOptions = batchImportOptions;
}
- ///
- /// 成员分页列表(含角色与已分配门店)
- ///
+ ///
public async Task> GetListAsync(TeamMemberGetListInputVo input)
{
var pageIndex = PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
var pageSize = input.MaxResultCount;
- var keyword = input.Keyword?.Trim();
-
RefAsync total = 0;
- // 先按 user 表筛选分页,再批量补齐角色与门店
- var users = await _userRepository._DbQueryable
- .Where(u => !u.IsDeleted)
- .WhereIF(!string.IsNullOrWhiteSpace(keyword),
- u => (u.Name != null && u.Name.Contains(keyword!)) ||
- u.UserName.Contains(keyword!) ||
- (u.Email != null && u.Email.Contains(keyword!)) ||
- (u.Phone != null && u.Phone.ToString()!.Contains(keyword!)))
- .WhereIF(input.State != null, u => u.State == input.State)
+ var query = await BuildFilteredUserQueryAsync(input);
+ var users = await query
.OrderByIF(!string.IsNullOrWhiteSpace(input.Sorting), input.Sorting!)
.OrderByDescending(u => u.CreationTime)
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
- var userIds = users.Select(x => x.Id).ToList();
- var userIdStrings = userIds.Select(x => x.ToString()).ToList();
-
- // user-role: 仅取第一个角色(原型表格展示单角色)
- var userRolePairs = await _dbContext.SqlSugarClient.Queryable((ur, r) => ur.RoleId == r.Id)
- .Where(ur => userIds.Contains(ur.UserId))
- .Select((ur, r) => new { ur.UserId, r.Id, r.RoleName })
- .ToListAsync();
-
- var roleMap = userRolePairs
- .GroupBy(x => x.UserId)
- .ToDictionary(g => g.Key, g => g.FirstOrDefault());
-
- // user-location
- var userLocations = await _dbContext.SqlSugarClient.Queryable()
- .Where(x => !x.IsDeleted)
- .WhereIF(!string.IsNullOrWhiteSpace(input.LocationId), x => x.LocationId == input.LocationId)
- .Where(x => userIdStrings.Contains(x.UserId))
- .ToListAsync();
-
- // 如果按 LocationId 过滤,需要反向过滤掉无关联的 user
- if (!string.IsNullOrWhiteSpace(input.LocationId))
- {
- var allowedUserIds = userLocations.Select(x => x.UserId).ToHashSet();
- users = users.Where(u => allowedUserIds.Contains(u.Id.ToString())).ToList();
- }
-
- var locationIds = userLocations.Select(x => x.LocationId).Distinct().ToList();
- var locations = await _dbContext.SqlSugarClient.Queryable()
- .Where(x => !x.IsDeleted)
- .WhereIF(locationIds.Count > 0, x => locationIds.Contains(x.Id.ToString()))
- .Select(x => new { x.Id, x.LocationCode, x.LocationName })
- .ToListAsync();
- var locationMap = locations.ToDictionary(x => x.Id.ToString(), x => x);
-
- var assignedMap = userLocations
- .GroupBy(x => x.UserId)
- .ToDictionary(
- g => g.Key,
- g => g.Select(x =>
- {
- if (locationMap.TryGetValue(x.LocationId, out var loc))
- {
- return new TeamMemberAssignedLocationDto
- {
- Id = loc.Id.ToString(),
- LocationCode = loc.LocationCode,
- LocationName = loc.LocationName
- };
- }
- return null;
- }).Where(x => x != null).Cast().ToList());
-
- var items = users.Select(u =>
- {
- roleMap.TryGetValue(u.Id, out var role);
- assignedMap.TryGetValue(u.Id.ToString(), out var assigned);
-
- return new TeamMemberGetListOutputDto
- {
- Id = u.Id,
- FullName = u.Name ?? string.Empty,
- UserName = u.UserName,
- Email = u.Email,
- Phone = u.Phone,
- State = u.State,
- RoleId = role?.Id,
- RoleName = role?.RoleName,
- AssignedLocations = assigned ?? new List()
- };
- }).ToList();
+ var items = await MapUsersToOutputAsync(
+ users,
+ input.LocationId,
+ restrictAssignedLocationsToFilter: !string.IsNullOrWhiteSpace(input.LocationId));
var totalCount = (long)total;
return new PagedResultWithPageDto
@@ -144,9 +76,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
};
}
- ///
- /// 成员详情(带门店ID列表)
- ///
+ ///
public async Task GetAsync(Guid id)
{
var user = await _userRepository.GetByIdAsync(id);
@@ -190,9 +120,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
};
}
- ///
- /// 新增成员(同步设置角色与门店)
- ///
+ ///
public async Task CreateAsync(TeamMemberCreateInputVo input)
{
if (input.LocationIds is null || input.LocationIds.Count == 0)
@@ -222,9 +150,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
return await GetAsync(user.Id);
}
- ///
- /// 编辑成员(同步设置角色与门店)
- ///
+ ///
public async Task UpdateAsync(Guid id, TeamMemberUpdateInputVo input)
{
if (input.LocationIds is null || input.LocationIds.Count == 0)
@@ -252,7 +178,6 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
await _userRepository.UpdateAsync(user);
- // 角色:覆盖式设置(只保留一个)
if (input.RoleId != null)
{
await _userManager.GiveUserSetRoleAsync(new List { id }, new List { input.RoleId.Value });
@@ -267,9 +192,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
return await GetAsync(id);
}
- ///
- /// 删除成员(逻辑删除 user;并逻辑删除关联表)
- ///
+ ///
public async Task DeleteAsync(Guid id)
{
var user = await _userRepository.GetByIdAsync(id);
@@ -294,6 +217,393 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
.ExecuteCommandAsync();
}
+ ///
+ public Task DownloadTeamMemberImportTemplateAsync()
+ {
+ var opt = _batchImportOptions.Value;
+ var dir = opt.TemplateDirectory?.Trim();
+ if (string.IsNullOrWhiteSpace(dir))
+ {
+ throw new UserFriendlyException("未配置批量导入模板目录 FoodLabeling:BatchImport:TemplateDirectory");
+ }
+
+ var fileName = opt.TeamMemberTemplateFileName?.Trim();
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ throw new UserFriendlyException("未配置模板文件名 FoodLabeling:BatchImport:TeamMemberTemplateFileName");
+ }
+
+ var fullPath = Path.Combine(dir, fileName);
+ if (!File.Exists(fullPath))
+ {
+ throw new UserFriendlyException($"模板文件不存在:{fullPath}");
+ }
+
+ var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+ return Task.FromResult(new FileStreamResult(stream, contentType)
+ {
+ FileDownloadName = fileName
+ });
+ }
+
+ ///
+ public async Task ExportTeamMembersPdfAsync([FromQuery] TeamMemberGetListInputVo input)
+ {
+ QuestPDF.Settings.License = LicenseType.Community;
+
+ var query = await BuildFilteredUserQueryAsync(input);
+ var users = await query
+ .OrderByIF(!string.IsNullOrWhiteSpace(input.Sorting), input.Sorting!)
+ .OrderByDescending(u => u.CreationTime)
+ .ToListAsync();
+
+ var rows = await MapUsersToOutputAsync(users, locationFilter: null, restrictAssignedLocationsToFilter: false);
+
+ var fileName = $"team-members_{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));
+ page.Header().Text("Team Members").SemiBold().FontSize(16);
+ page.Content().PaddingTop(8).Table(table =>
+ {
+ table.ColumnsDefinition(c =>
+ {
+ c.RelativeColumn(1.4f);
+ c.RelativeColumn(1.6f);
+ c.RelativeColumn(1.1f);
+ c.RelativeColumn(1.1f);
+ c.RelativeColumn(2.2f);
+ c.RelativeColumn(0.7f);
+ });
+
+ static IContainer CellHeader(IContainer c) =>
+ c.Background(Colors.Grey.Lighten3).Padding(4).DefaultTextStyle(x => x.SemiBold());
+
+ table.Cell().Element(CellHeader).Text("Name");
+ table.Cell().Element(CellHeader).Text("Email");
+ table.Cell().Element(CellHeader).Text("Phone");
+ table.Cell().Element(CellHeader).Text("Role");
+ table.Cell().Element(CellHeader).Text("Assigned Locations");
+ table.Cell().Element(CellHeader).Text("Status");
+
+ foreach (var e in rows)
+ {
+ var locText = e.AssignedLocations.Count == 0
+ ? "无"
+ : string.Join("; ",
+ e.AssignedLocations.Select(a =>
+ $"{a.LocationCode} - {a.LocationName}"));
+ var status = e.State ? "Active" : "Inactive";
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(e.FullName ?? string.Empty);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(e.Email ?? "无");
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(e.Phone?.ToString() ?? "无");
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(string.IsNullOrWhiteSpace(e.RoleName) ? "无" : e.RoleName);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(locText);
+ table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
+ .Text(status);
+ }
+ });
+ });
+ });
+
+ var stream = new MemoryStream();
+ document.GeneratePdf(stream);
+ stream.Position = 0;
+ return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
+ }
+
+ ///
+ public async Task ImportTeamMembersBatchAsync(
+ [FromForm] TeamMemberBatchImportInputVo input)
+ {
+ if (input?.File is null || input.File.Length == 0)
+ {
+ throw new UserFriendlyException("请上传 Excel 文件(form 字段名:file)");
+ }
+
+ var opt = _batchImportOptions.Value;
+ if (input.File.Length > opt.MaxUploadBytes)
+ {
+ throw new UserFriendlyException($"文件过大,最大允许 {opt.MaxUploadBytes / 1024 / 1024} MB");
+ }
+
+ var ext = Path.GetExtension(input.File.FileName)?.ToLowerInvariant();
+ if (ext != ".xlsx")
+ {
+ throw new UserFriendlyException("仅支持 .xlsx 格式的 Excel 文件");
+ }
+
+ var roleMap = await BuildRoleNameToIdMapAsync();
+ await using var uploadStream = input.File.OpenReadStream();
+ var parseErrors = new List();
+ var rows = TeamMemberBatchExcelHelper.ParseImportWorkbook(
+ uploadStream,
+ opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows,
+ roleMap,
+ opt.TeamMemberImportDefaultPassword?.Trim() ?? string.Empty,
+ out var headerErrors);
+ parseErrors.AddRange(headerErrors);
+
+ var result = new TeamMemberBatchImportResultDto();
+ if (rows.Count == 0 && parseErrors.Count > 0)
+ {
+ result.Errors = parseErrors;
+ result.FailCount = parseErrors.Count;
+ return result;
+ }
+
+ foreach (var (rowNum, vo) in rows)
+ {
+ try
+ {
+ vo.LocationIds = await ResolveLocationIdsFromImportTokensAsync(vo.LocationIds);
+ await CreateAsync(vo);
+ result.SuccessCount++;
+ }
+ catch (UserFriendlyException ex)
+ {
+ result.FailCount++;
+ result.Errors.Add(new TeamMemberBatchImportErrorDto
+ {
+ RowNumber = rowNum,
+ UserName = vo.UserName,
+ Message = ex.Message
+ });
+ }
+ }
+
+ result.Errors.InsertRange(0, parseErrors);
+ return result;
+ }
+
+ ///
+ public async Task UpdateTeamMembersBulkAsync(
+ [FromBody] TeamMemberBulkUpdateInputVo input)
+ {
+ if (input?.Items is null || input.Items.Count == 0)
+ {
+ throw new UserFriendlyException("请至少提交一条编辑数据(items 不能为空)");
+ }
+
+ var opt = _batchImportOptions.Value;
+ var maxItems = opt.MaxBulkUpdateItems <= 0 ? 500 : opt.MaxBulkUpdateItems;
+ if (input.Items.Count > maxItems)
+ {
+ throw new UserFriendlyException($"单次批量编辑最多允许 {maxItems} 条,请分批提交");
+ }
+
+ var effectiveCount = input.Items.Count(static x => x is not null && x.Id != Guid.Empty);
+ if (effectiveCount == 0)
+ {
+ throw new UserFriendlyException("没有有效的成员 Id(请为待保存行填写 id)");
+ }
+
+ var result = new TeamMemberBulkUpdateResultDto();
+ for (var i = 0; i < input.Items.Count; i++)
+ {
+ var item = input.Items[i];
+ if (item is null || item.Id == Guid.Empty)
+ {
+ continue;
+ }
+
+ try
+ {
+ await UpdateAsync(item.Id, item);
+ result.SuccessCount++;
+ }
+ catch (UserFriendlyException ex)
+ {
+ result.FailCount++;
+ result.Errors.Add(new TeamMemberBulkUpdateErrorDto
+ {
+ RowNumber = i + 1,
+ Id = item.Id,
+ Message = ex.Message
+ });
+ }
+ }
+
+ return result;
+ }
+
+ private async Task> BuildRoleNameToIdMapAsync()
+ {
+ var roles = await _dbContext.SqlSugarClient.Queryable()
+ .Where(r => !r.IsDeleted)
+ .Select(r => new { r.Id, r.RoleName })
+ .ToListAsync();
+
+ return roles
+ .Where(r => !string.IsNullOrWhiteSpace(r.RoleName))
+ .GroupBy(r => TeamMemberBatchExcelHelper.NormalizeRoleKey(r.RoleName!))
+ .ToDictionary(g => g.Key, g => g.First().Id);
+ }
+
+ private async Task> ResolveLocationIdsFromImportTokensAsync(List tokens)
+ {
+ var result = new List();
+ foreach (var raw in tokens)
+ {
+ var s = raw.Trim();
+ if (string.IsNullOrEmpty(s))
+ {
+ continue;
+ }
+
+ var idx = s.IndexOf(" -", StringComparison.Ordinal);
+ var key = idx > 0 ? s[..idx].Trim() : s.Trim();
+ if (Guid.TryParse(key, out var gid))
+ {
+ var byId = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted && x.Id == gid)
+ .FirstAsync();
+ if (byId is null)
+ {
+ throw new UserFriendlyException($"无效门店 Id:{key}");
+ }
+
+ result.Add(byId.Id.ToString());
+ continue;
+ }
+
+ var byCode = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted && x.LocationCode == key)
+ .FirstAsync();
+ if (byCode is null)
+ {
+ throw new UserFriendlyException($"未找到门店编码:{key}");
+ }
+
+ result.Add(byCode.Id.ToString());
+ }
+
+ return result.Distinct().ToList();
+ }
+
+ private async Task> BuildFilteredUserQueryAsync(TeamMemberGetListInputVo input)
+ {
+ var keyword = input.Keyword?.Trim();
+ var query = _userRepository._DbQueryable
+ .Where(u => !u.IsDeleted)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword),
+ u => (u.Name != null && u.Name.Contains(keyword!)) ||
+ u.UserName.Contains(keyword!) ||
+ (u.Email != null && u.Email.Contains(keyword!)) ||
+ (u.Phone != null && u.Phone.ToString()!.Contains(keyword!)))
+ .WhereIF(input.State != null, u => u.State == input.State);
+
+ if (input.RoleId != null)
+ {
+ var userIds = await _dbContext.SqlSugarClient.Queryable()
+ .Where(ur => ur.RoleId == input.RoleId.Value)
+ .Select(ur => ur.UserId)
+ .ToListAsync();
+ query = query.Where(u => userIds.Contains(u.Id));
+ }
+
+ if (!string.IsNullOrWhiteSpace(input.LocationId))
+ {
+ var locId = input.LocationId.Trim();
+ var userIdStrs = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted && x.LocationId == locId)
+ .Select(x => x.UserId)
+ .ToListAsync();
+ var allowed = new HashSet(userIdStrs);
+ query = query.Where(u => allowed.Contains(u.Id.ToString()));
+ }
+
+ return query;
+ }
+
+ private async Task> MapUsersToOutputAsync(
+ List users,
+ string? locationFilter,
+ bool restrictAssignedLocationsToFilter)
+ {
+ if (users.Count == 0)
+ {
+ return new List();
+ }
+
+ var userIds = users.Select(x => x.Id).ToList();
+ var userIdStrings = userIds.Select(x => x.ToString()).ToList();
+
+ var userRolePairs = await _dbContext.SqlSugarClient.Queryable((ur, r) => ur.RoleId == r.Id)
+ .Where(ur => userIds.Contains(ur.UserId))
+ .Select((ur, r) => new { ur.UserId, r.Id, r.RoleName })
+ .ToListAsync();
+
+ var roleMap = userRolePairs
+ .GroupBy(x => x.UserId)
+ .ToDictionary(g => g.Key, g => g.FirstOrDefault());
+
+ var userLocQuery = _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted)
+ .Where(x => userIdStrings.Contains(x.UserId));
+ if (restrictAssignedLocationsToFilter && !string.IsNullOrWhiteSpace(locationFilter))
+ {
+ userLocQuery = userLocQuery.Where(x => x.LocationId == locationFilter.Trim());
+ }
+
+ var userLocations = await userLocQuery.ToListAsync();
+
+ var locationIds = userLocations.Select(x => x.LocationId).Distinct().ToList();
+ var locations = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted)
+ .WhereIF(locationIds.Count > 0, x => locationIds.Contains(x.Id.ToString()))
+ .Select(x => new { x.Id, x.LocationCode, x.LocationName })
+ .ToListAsync();
+ var locationMap = locations.ToDictionary(x => x.Id.ToString(), x => x);
+
+ var assignedMap = userLocations
+ .GroupBy(x => x.UserId)
+ .ToDictionary(
+ g => g.Key,
+ g => g.Select(x =>
+ {
+ if (locationMap.TryGetValue(x.LocationId, out var loc))
+ {
+ return new TeamMemberAssignedLocationDto
+ {
+ Id = loc.Id.ToString(),
+ LocationCode = loc.LocationCode,
+ LocationName = loc.LocationName
+ };
+ }
+
+ return null;
+ }).Where(x => x != null).Cast().ToList());
+
+ return users.Select(u =>
+ {
+ roleMap.TryGetValue(u.Id, out var role);
+ assignedMap.TryGetValue(u.Id.ToString(), out var assigned);
+
+ return new TeamMemberGetListOutputDto
+ {
+ Id = u.Id,
+ FullName = u.Name ?? string.Empty,
+ UserName = u.UserName,
+ Email = u.Email,
+ Phone = u.Phone,
+ State = u.State,
+ RoleId = role?.Id,
+ RoleName = role?.RoleName,
+ AssignedLocations = assigned ?? new List()
+ };
+ }).ToList();
+ }
+
private async Task UpsertUserLocationsAsync(Guid userId, List locationIds)
{
var now = DateTime.Now;
@@ -301,7 +611,6 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
var wanted = locationIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToList();
var currentUserId = CurrentUser?.Id?.ToString();
- // 校验门店存在且未删除
var validCount = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted)
.Where(x => wanted.Contains(x.Id.ToString()))
@@ -318,7 +627,6 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
var existingActive = existing.Where(x => !x.IsDeleted).ToList();
var existingActiveSet = existingActive.Select(x => x.LocationId).ToHashSet();
- // 需要删除的(逻辑删除)
var toDelete = existingActive.Where(x => !wanted.Contains(x.LocationId)).ToList();
if (toDelete.Count > 0)
{
@@ -334,7 +642,6 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
.ExecuteCommandAsync();
}
- // 需要新增的
var toInsert = wanted.Where(x => !existingActiveSet.Contains(x)).ToList();
if (toInsert.Count > 0)
{
@@ -353,4 +660,3 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
}
}
}
-
diff --git a/美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx b/美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx
index 15ef376..f75fb3e 100755
--- a/美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx
+++ b/美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx
@@ -17,7 +17,7 @@ import { getPartners } from "../../services/partnerService";
import { getGroups } from "../../services/groupService";
import {
exportLabelReportPdf,
- exportPrintLogPdf,
+ exportPrintLogExcel,
getLabelReport,
getReportsPrintLogList,
reprintPrintLog,
@@ -375,7 +375,7 @@ export function ReportsView({
setExporting(true);
try {
if (activeTab === "print-log") {
- await exportPrintLogPdf({
+ await exportPrintLogExcel({
...f,
skipCount: 1,
maxResultCount: 10,
@@ -384,7 +384,9 @@ export function ReportsView({
} else {
await exportLabelReportPdf(f);
}
- toast.success("Export ready", { description: "The PDF download should start shortly." });
+ toast.success("Export ready", {
+ description: activeTab === "print-log" ? "The Excel download should start shortly." : "The PDF download should start shortly.",
+ });
} catch (e) {
const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Please try again.";
toast.error("Export failed", { description: msg });
@@ -516,7 +518,8 @@ export function ReportsView({
disabled={exporting}
onClick={() => void handleExport()}
>
- {exporting ? "Exporting…" : "Export Report"}
+ {" "}
+ {exporting ? "Exporting…" : activeTab === "print-log" ? "Export Excel" : "Export PDF"}
diff --git a/美国版/Food Labeling Management Platform/src/services/reportsService.ts b/美国版/Food Labeling Management Platform/src/services/reportsService.ts
index 3cbdbc8..ff27ec7 100644
--- a/美国版/Food Labeling Management Platform/src/services/reportsService.ts
+++ b/美国版/Food Labeling Management Platform/src/services/reportsService.ts
@@ -385,6 +385,43 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi
URL.revokeObjectURL(url);
}
+/** GET /api/app/reports/export-print-log-excel — 与 Print Log 列表筛选一致,全量 xlsx */
+export async function exportPrintLogExcel(input: ReportsPrintLogQueryInput): Promise {
+ const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001";
+ const path = joinUrl(
+ baseUrl,
+ `/api/app${REPORTS_PREFIX}/export-print-log-excel${buildPrintLogExportQuery(input)}`
+ );
+ const token = getTokenForFetch();
+ const res = await fetch(path, { method: "GET", headers: token ? { Authorization: `Bearer ${token}` } : {} });
+ const ct = res.headers.get("content-type") ?? "";
+ if (!res.ok) {
+ if (ct.includes("application/json")) {
+ const payload = await res.json().catch(() => null);
+ const p = payload as { error?: { message?: string } };
+ const msg = p?.error?.message?.trim() || "Export failed.";
+ throw new ApiError(msg, res.status, payload);
+ }
+ const t = await res.text().catch(() => "");
+ throw new ApiError(t || "Export failed.", res.status, t);
+ }
+ if (ct.includes("application/json")) {
+ const payload = await res.json().catch(() => null);
+ const p = payload as { error?: { message?: string } };
+ const msg = p?.error?.message?.trim() || "Export failed.";
+ throw new ApiError(msg, res.status, payload);
+ }
+ const blob = await res.blob();
+ const name =
+ parseFileNameFromContentDisposition(res.headers.get("content-disposition")) || "print-log-export.xlsx";
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = name;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
export async function exportLabelReportPdf(input: LabelReportQueryInput): Promise {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001";
const path = joinUrl(
diff --git a/项目相关文档/Account-Management-批量导入模板/Location-Manager-批量导入模板.csv b/项目相关文档/Account-Management-批量导入模板/Location-Manager-批量导入模板.csv
new file mode 100644
index 0000000..87e55c8
--- /dev/null
+++ b/项目相关文档/Account-Management-批量导入模板/Location-Manager-批量导入模板.csv
@@ -0,0 +1,3 @@
+Company,Region,Location ID,Location Name,Street,City,State,Country,Zip Code,Phone,Email,Latitude,Longitude,Active
+MedVantage Cafe Group,MedVantage Cafe North Carolina Region,444444,UNCC store,222 School House Lane,Charlotte,NC,USA,29889,2123456789,nc@123.com,35.3071,-80.7356,TRUE
+,,,,,,,,,,,,,
diff --git a/项目相关文档/Account-Management-批量导入模板/Product-Manager-批量导入模板.xlsx b/项目相关文档/Account-Management-批量导入模板/Product-Manager-批量导入模板.xlsx
new file mode 100644
index 0000000..6a80b46
--- /dev/null
+++ b/项目相关文档/Account-Management-批量导入模板/Product-Manager-批量导入模板.xlsx
diff --git a/项目相关文档/Account-Management-批量导入模板/Team-Member-批量导入模板.csv b/项目相关文档/Account-Management-批量导入模板/Team-Member-批量导入模板.csv
new file mode 100644
index 0000000..32ae262
--- /dev/null
+++ b/项目相关文档/Account-Management-批量导入模板/Team-Member-批量导入模板.csv
@@ -0,0 +1,3 @@
+Name,User Name (Login),Password,Email,Phone,Role Id,Role Name (仅说明勿删),Assigned Location Ids,Status
+John Doe,john.doe,ChangeMe123!,john@123.com,789654444,"(必填)从系统角色列表复制 Role Id","Staff","门店Guid1;门店Guid2",TRUE
+,,,,,,,,,
diff --git a/项目相关文档/批量导入导出接口说明.md b/项目相关文档/批量导入导出接口说明.md
new file mode 100644
index 0000000..8fcd1a0
--- /dev/null
+++ b/项目相关文档/批量导入导出接口说明.md
@@ -0,0 +1,432 @@
+# 美国版 · 批量导入 / 批量导出(Excel·PDF)/ 下载模板 / 批量编辑 — 接口汇总
+
+本文档集中维护 **Account Management**、**Reports** 及相关模块的「下载 Excel 模板」「批量导出(Excel 或 PDF)」「批量导入 Excel」以及 **网格「保存全部」式批量编辑(JSON)** 等接口。**单条 CRUD、分页列表**仍以各业务模块说明为准(如门店见 `门店(Location)接口对接说明.md`)。
+
+---
+
+## 目录
+
+| 章节 | 内容 |
+|------|------|
+| [公共约定](#公共约定) | 基址、鉴权、Swagger、通用注意事项 |
+| [共享配置](#共享配置) | `appsettings` 中 `FoodLabeling:BatchImport` |
+| [1 Location Manager(门店)](#1-location-manager门店) | 下载模板 / Excel 导出 / 导入 / 批量编辑 |
+| [2 Team Member(成员)](#2-team-member成员) | 下载模板 / **PDF 全量导出** / Excel 导入 / 批量编辑 |
+| [3 Products(菜单-产品)](#3-products菜单-产品) | 下载模板 / Excel 全量导出 / Excel 导入 / 批量编辑 |
+| [4 后续模块(预留)](#4-后续模块预留) | 新接口在此追加小节 |
+| [5 Account Management(Company / Region)](#5-account-managementcompany-region) | **PDF 全量导出**(Company、Region 页签) |
+| [6 Reports — Print Log(Excel)](#6-reports--print-logexcel) | **Excel 全量导出**(Print Log 页签) |
+| [附录 curl 模板](#附录-curl-模板) | 登录、Location / Team Member / Products / Company / Region / Reports 调用示例 |
+
+---
+
+## 公共约定
+
+- **宿主**:美国版后端 `Yi.Abp.Web`;本地 Swagger 示例:`http://localhost:19001/swagger`。
+- **路由前缀**:约定式控制器 `RootPath` 为 **`api/app`**(与 ABP 实际配置一致)。
+- **Swagger 分组**:**「食品标签-美国版接口」**;具体路径以 Swagger 展示为准(下表为常见命名,联调时请以 Swagger 为准)。
+- **鉴权**:与其它业务接口相同,请求头携带登录接口返回的 **`data.token` 完整值**(已含 `Bearer ` 前缀),示例:`Authorization: {data.token}`。
+- **文件类响应**:`Content-Type` 多为 `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`(`.xlsx`)。
+- **导入类请求**:统一使用 **`multipart/form-data`**,文件字段名以各接口说明为准(Location 为 **`file`**)。
+- **批量编辑类请求**:使用 **`application/json`**,一次提交多行(与前端表格「保存全部」对齐)。
+- **导出类响应**:Location 与 **Products(菜单-产品)**、**Reports — Print Log** 为 **Excel 全量**;**Team Member**、**Account Management 的 Company / Region**、**Reports — Label Report** 等为 **PDF**(见各小节);数据量极大时请注意服务端内存与响应耗时。
+
+---
+
+## 共享配置
+
+配置节全名:`FoodLabeling:BatchImport`(绑定类:`FoodLabelingBatchImportOptions`,在 `FoodLabelingApplicationModule` 中注册)。
+
+| 配置项 | 说明 |
+|--------|------|
+| `TemplateDirectory` | 服务器上存放**批量导入模板**的目录(生产示例:`/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles`) |
+| `LocationTemplateFileName` | Location 模板文件名(默认:`Location-Manager-批量导入模板.xlsx`) |
+| `TeamMemberTemplateFileName` | Team Member 模板文件名(默认:`Team-Member-批量导入模板.xlsx`) |
+| `ProductTemplateFileName` | Product(菜单-产品)模板文件名(默认:`Product-Manager-批量导入模板.xlsx`) |
+| `TeamMemberImportDefaultPassword` | Team Member 批量导入时,Excel 未填 Password 列则使用的默认初始密码 |
+| `MaxImportRows` | 单次导入最多数据行数(默认 5000) |
+| `MaxUploadBytes` | 上传 Excel 最大字节数(默认 10MB) |
+| `MaxBulkUpdateItems` | 单次「批量编辑」请求中 **`items` 数组最大长度**(默认 500;含占位空行,与前端网格行数一致) |
+
+后续若增加其它模块模板文件名等,可在此表同一节下扩展配置项说明(并与 `appsettings`、Options 类保持一致)。
+
+---
+
+## 1 Location Manager(门店)
+
+**应用服务**:`LocationAppService`(模块 `food-labeling-us`)。
+
+**列表筛选字段**(导出与列表对齐时):`Sorting`、`Keyword`、`Partner`、`GroupName`、`State` — 含义与分页列表一致,详见 `门店(Location)接口对接说明.md` 接口 1。
+
+### 1.1 下载批量导入模板
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `DownloadLocationImportTemplateAsync` |
+| HTTP | `GET` |
+| 常见路径 | `/api/app/location/download-location-import-template` |
+| 作用 | 从 `TemplateDirectory` 读取 `LocationTemplateFileName` 指向的文件并作为附件下载 |
+| 失败常见原因 | 未配置目录、文件名、或服务器上文件不存在 |
+
+### 1.2 批量导出 Excel
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `ExportLocationsExcelAsync` |
+| HTTP | `GET` |
+| 常见路径 | `/api/app/location/export-locations-excel` |
+| Query | 与门店列表筛选一致:`Sorting`、`Keyword`、`Partner`、`GroupName`、`State` |
+| 数据范围 | **全量**:符合筛选条件的全部记录;**不使用**请求中的 `SkipCount` / `MaxResultCount`(与列表分页无关) |
+| 排序 | 与列表一致:有 `Sorting` 则按其排序,否则默认 `CreationTime` 降序 |
+| 响应文件名示例 | `locations-export-yyyyMMdd-HHmmss.xlsx` |
+
+### 1.3 批量导入 Excel
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `ImportLocationsBatchAsync` |
+| HTTP | `POST` |
+| Content-Type | `multipart/form-data` |
+| 常见路径 | `/api/app/location/import-locations-batch` |
+| 表单字段 | **`file`**:仅支持 `.xlsx` |
+| 返回类型 | `LocationBatchImportResultDto`(JSON) |
+
+**`LocationBatchImportResultDto` 字段**
+
+| 字段 | 说明 |
+|------|------|
+| `SuccessCount` | 成功新增条数 |
+| `FailCount` | 失败条数(含解析错误与逐行业务校验失败) |
+| `SkippedEmptyRows` | 预留,当前一般为 `0` |
+| `Errors` | `RowNumber`、`LocationCode`、`Message` |
+
+**解析与业务摘要**
+
+- 表头须能识别 **Location ID**(或同义列);建议使用官方模板。
+- **必填**:Location ID(`LocationCode`)、Location Name(与单条新增一致)。
+- **可选**:Company/Region、地址、电话、邮箱、经纬度、`Active` 等;经纬度为空则 `null`,有值则校验格式。
+
+### 1.4 批量编辑(网格「保存全部」)
+
+对应前端在 **Location Manager** 进入批量编辑页后,将多行修改一次性提交;**每行通过主键 `id` 定位记录**,可编辑字段与单条 **`PUT /api/app/location/{id}`** 的 body(`LocationUpdateInputVo`)一致。**不修改 Location ID(`LocationCode`)**(与单条更新接口一致)。
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `UpdateLocationsBulkAsync` |
+| HTTP | `POST` |
+| Content-Type | `application/json` |
+| 常见路径 | `/api/app/location/update-locations-bulk` |
+| Body | `LocationBulkUpdateInputVo` |
+
+**请求体 `LocationBulkUpdateInputVo`**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `items` | 数组 | 每一元素为 `LocationBulkUpdateItemVo` |
+
+**`LocationBulkUpdateItemVo`(单行)**
+
+| 字段 | 说明 |
+|------|------|
+| `id` | **Guid**,列表接口返回的门店主键;为 **`00000000-0000-0000-0000-000000000000`** 或未填写的占位行将被**忽略**(便于与「至少 10 行空行」类 UI 对齐) |
+| `partner` | 可选,Company |
+| `groupName` | 可选,Region |
+| `locationName` | **必填**(与单条更新校验一致) |
+| `street` / `city` / `stateCode` / `country` / `zipCode` / `phone` / `email` | 可选 |
+| `latitude` / `longitude` | 可选,decimal |
+| `state` | 是否启用,默认 `true` |
+
+**返回 `LocationBulkUpdateResultDto`**
+
+| 字段 | 说明 |
+|------|------|
+| `SuccessCount` | 成功更新条数 |
+| `FailCount` | 失败条数 |
+| `Errors` | `LocationBulkUpdateErrorDto`:`rowNumber`(在 **`items` 数组中的序号,从 1 开始**)、`id`、`message` |
+
+**行为说明**
+
+- **逐条提交**:内部对每一有效行调用与单条更新相同的业务逻辑;**一行失败不影响其它行**。
+- **整单校验**:`items` 为空、超过 `MaxBulkUpdateItems`、或没有任何有效 `id` 时返回 **400** 类业务错误(`UserFriendlyException`)。
+- **JSON 命名**:与项目其它接口一致,一般为 **camelCase**(以实际 JSON 序列化配置为准)。
+
+---
+
+## 2 Team Member(成员)
+
+**应用服务**:`TeamMemberAppService`(模块 `food-labeling-us`;前端常见路径前缀 **`/team-member`**)。
+
+**列表筛选字段**(导出与列表对齐):`Keyword`、`RoleId`、`LocationId`、`State`、`Sorting` — 与成员分页列表一致(`LocationId` 为门店主键字符串,与 `UserLocation.LocationId` 一致)。
+
+### 2.1 下载批量导入模板
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `DownloadTeamMemberImportTemplateAsync` |
+| HTTP | `GET` |
+| 常见路径 | `/api/app/team-member/download-team-member-import-template` |
+| 作用 | 从 `TemplateDirectory` 读取 `TeamMemberTemplateFileName` 指向的 xlsx 并下载 |
+
+### 2.2 批量导出 PDF(全量)
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `ExportTeamMembersPdfAsync` |
+| HTTP | `GET` |
+| 常见路径 | `/api/app/team-member/export-team-members-pdf` |
+| Query | 与成员列表筛选一致:`Keyword`、`RoleId`、`LocationId`、`State`、`Sorting` |
+| 数据范围 | **全量**:符合筛选条件的全部成员;**不使用** `SkipCount` / `MaxResultCount` |
+| 排序 | 有 `Sorting` 则按其排序,否则按创建时间降序 |
+| 响应 | `Content-Type: application/pdf`,文件名示例 `team-members_yyyy-MM-dd_HH-mm-ss.pdf` |
+| PDF 列 | Name、Email、Phone、Role、Assigned Locations(多门店以分号拼接)、Status(Active/Inactive) |
+
+**说明**:PDF 中「Assigned Locations」展示该成员**全部**已分配门店(不受列表按门店筛选时「仅显示命中门店」的收缩影响),便于导出后审阅完整权限。
+
+### 2.3 批量导入 Excel
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `ImportTeamMembersBatchAsync` |
+| HTTP | `POST` |
+| Content-Type | `multipart/form-data` |
+| 常见路径 | `/api/app/team-member/import-team-members-batch` |
+| 表单字段 | **`file`**,仅 `.xlsx` |
+| 返回 | `TeamMemberBatchImportResultDto`:`successCount`、`failCount`、`errors`(`rowNumber`、`userName`、`message`) |
+
+**表头识别(摘要)**
+
+- **必填列**:`Name`(或 FullName)、`Email`;**Role**;**Assigned Locations**(至少一条)。
+- **可选列**:`UserName` / `Login`(不填则登录账号用 Email)、`Password`(不填则用配置 **`TeamMemberImportDefaultPassword`**)、`Phone`、`Status`。
+- **Assigned Locations**:多个门店可用 **`;`**、`|`、换行、中文 **`,`** 分隔;支持 `33333 - Central Park Store`(取 **` -`** 前为门店编码或 Guid)。
+- **Role**:与系统 **`Role.RoleName`** 一致(忽略大小写与中间空格);未匹配则该行失败。
+
+内部对每行调用与单条创建相同的业务逻辑;**单行失败不影响其它行**。
+
+### 2.4 批量编辑(网格「保存全部」)
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `UpdateTeamMembersBulkAsync` |
+| HTTP | `POST` |
+| Content-Type | `application/json` |
+| 常见路径 | `/api/app/team-member/update-team-members-bulk` |
+| Body | `TeamMemberBulkUpdateInputVo`:`items` 为 `TeamMemberBulkUpdateItemVo` 数组 |
+
+每行含 **`id`(成员 Guid)** 及与单条 **`PUT /api/app/team-member/{id}`** 相同的字段(`password` 可空表示不改密码)。`id` 为全零 GUID 的项忽略。整单规则与 Location 批量编辑相同(`MaxBulkUpdateItems`、至少一条有效 `id` 等)。
+
+**返回** `TeamMemberBulkUpdateResultDto`:`successCount`、`failCount`、`errors`(`rowNumber`、`id`、`message`)。
+
+---
+
+## 3 Products(菜单-产品)
+
+**应用服务**:`ProductAppService`(模块 `food-labeling-us`;前端常见路径前缀 **`/product`**)。
+
+**列表筛选字段**(导出与列表对齐):`Keyword`、`State`、`Sorting` — 与产品分页列表一致(`Keyword` 匹配产品编码、名称、分类名称)。
+
+### 3.1 下载批量导入模板
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `DownloadProductImportTemplateAsync` |
+| HTTP | `GET` |
+| 常见路径 | `/api/app/product/download-product-import-template` |
+| 作用 | 从 `TemplateDirectory` 读取 `ProductTemplateFileName` 指向的 xlsx 并下载(工作表名一般为 `Products`) |
+
+### 3.2 批量导出 Excel(全量)
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `ExportProductsExcelAsync` |
+| HTTP | `GET` |
+| 常见路径 | `/api/app/product/export-products-excel` |
+| Query | 与产品列表筛选一致:`Keyword`、`State`、`Sorting` |
+| 数据范围 | **全量**:符合筛选条件的全部产品;**不使用** `SkipCount` / `MaxResultCount` |
+| 排序 | 有 `Sorting` 则按其排序,否则默认按 `ProductName` 降序 |
+| 响应文件名示例 | `products-export-yyyyMMdd-HHmmss.xlsx` |
+| 列(与导入模板一致) | **Location**(多门店英文逗号拼接门店名称)、**Product Category**(分类名称)、**Product**(产品名称)、**Product Code**(产品编码;可为空则导出为空单元格) |
+
+### 3.3 批量导入 Excel
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `ImportProductsBatchAsync` |
+| HTTP | `POST` |
+| Content-Type | `multipart/form-data` |
+| 常见路径 | `/api/app/product/import-products-batch` |
+| 表单字段 | **`file`**,仅 `.xlsx` |
+| 返回 | `ProductBatchImportResultDto`:`successCount`、`failCount`、`errors`(`rowNumber`、`productName`、`message`) |
+
+**表头识别(摘要)**
+
+- **必填列**:`Product Category`(分类**名称**,与 `fl_product_category.CategoryName` 匹配,忽略大小写;若同名多条则该行失败)、`Product`(产品名称)。
+- **可选列**:`Location`(多门店可用英文逗号 **`,`**、中文逗号、分号、竖线、换行分隔;每个片段:若为 **Guid** 则按门店主键;否则按 **Location Code** 精确匹配,或按 **Location Name** 不区分大小写匹配;**同一片段匹配到多条门店**则该行失败)、`Product Code`(可空,空则创建时由后端生成唯一编码,与单条创建一致)。
+- **不在模板中的字段**:产品主键、启用状态由后端处理;导入创建的产品 **`state` 恒为 `true`(启用)**;`productImageUrl` 不通过本导入写入。
+
+内部对每行调用与单条 **`POST` 创建产品** 相同的业务逻辑(含门店关联写入);**单行失败不影响其它行**。
+
+### 3.4 批量编辑(网格「保存全部」)
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `UpdateProductsBulkAsync` |
+| HTTP | `POST` |
+| Content-Type | `application/json` |
+| 常见路径 | `/api/app/product/update-products-bulk` |
+| Body | `ProductBulkUpdateInputVo`:`items` 为 `ProductBulkUpdateItemVo` 数组 |
+
+每行含 **`id`(产品主键字符串,与列表/详情返回的 `id` 一致)** 及与单条 **`PUT /api/app/product/{id}`** 相同的 body 字段(`ProductUpdateInputVo` / `ProductCreateInputVo` 形状:`productCode`、`productName`、`categoryId`、`productImageUrl`、`state`、`locationIds`)。`id` 为空或仅空白的项**忽略**。整单规则与 Location / Team Member 批量编辑相同(`MaxBulkUpdateItems`、至少一条有效 `id` 等)。
+
+**返回** `ProductBulkUpdateResultDto`:`successCount`、`failCount`、`errors`(`rowNumber`、`id`、`message`)。
+
+---
+
+## 4 后续模块(预留)
+
+> 新模块的批量能力可在此追加 **## 7 xxx** 等章节,并更新文首 **目录** 与 **共享配置** 表(本节为占位,章节号可按实际顺延)。
+
+---
+
+## 5 Account Management(Company / Region)
+
+前端 **Account Management** 菜单中 **Company**、**Region** 两个页签的「Bulk Export (PDF)」对应后端已有接口:数据模型分别为 **`fl_partner`**(合作伙伴 / 公司)、**`fl_group`**(组织 / 大区);应用服务为 **`PartnerAppService`**、**`GroupAppService`**。导出为 **QuestPDF** 生成的 **PDF**,筛选条件与各自**分页列表**一致,**不使用** `SkipCount` / `MaxResultCount`(全量导出)。单次导出超过 **5000** 条时返回业务错误,需缩小筛选范围。
+
+### 5.1 Company(合作伙伴 / `PartnerAppService`)
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `ExportPdfAsync` |
+| HTTP | `GET` |
+| 常见路径 | `/api/app/partner/export-pdf` |
+| Query | 与 Company 列表一致:`Keyword`、`State`、`Sorting` |
+| 数据范围 | 符合筛选条件的**全部**记录(全量) |
+| 排序 | 与列表 `GetListAsync` 内 `BuildPartnerListQuery` 一致(含 `Sorting` 各分支;无则 `CreationTime` 降序) |
+| 响应 | `Content-Type: application/pdf`,文件名示例 `companies_yyyy-MM-dd_HH-mm-ss.pdf` |
+| PDF 列 | Company(公司名称)、Contact(邮箱)、Phone、Status(active/inactive)、Created |
+
+### 5.2 Region(组织 / `GroupAppService`)
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `ExportPdfAsync` |
+| HTTP | `GET` |
+| 常见路径 | `/api/app/group/export-pdf` |
+| Query | 与 Region 列表一致:`Keyword`、`PartnerId`(下拉「所属公司」对应 `fl_partner.Id`)、`State`、`Sorting` |
+| 数据范围 | 符合筛选条件的**全部**记录(全量) |
+| 排序 | 与列表 `GetListAsync` 内 `BuildGroupJoinedQuery` 一致 |
+| 响应 | `Content-Type: application/pdf`,文件名示例 `regions_yyyy-MM-dd_HH-mm-ss.pdf` |
+| PDF 列 | Region Name、Parent company、Status(active/inactive)、Created |
+
+**说明**:前端 `partnerService.exportPartnersPdf` / `groupService.exportGroupsPdf` 已按上述路径封装;鉴权与其它 `GET` 一致。
+
+---
+
+## 6 Reports — Print Log(Excel)
+
+**应用服务**:`ReportsAppService`(模块 `food-labeling-us`)。前端 **Reports** 菜单 **Print Log** 页签的「Export Report」在实现上调用 **Excel 全量导出**(与列表同一套筛选;**不使用**分页参数参与数据范围,仅可选用 `Sorting` 与列表对齐)。
+
+### 6.1 Print Log 批量导出 Excel
+
+| 项目 | 说明 |
+|------|------|
+| 方法 | `ExportPrintLogExcelAsync` |
+| HTTP | `GET` |
+| 常见路径 | `/api/app/reports/export-print-log-excel` |
+| Query | 与 Print Log 分页列表一致:`PartnerId`、`GroupId`、`LocationId`、`StartDate`、`EndDate`、`Keyword`、`Sorting`(**不传** `SkipCount` / `MaxResultCount` 或传了也会被后端忽略;全量以筛选为准) |
+| 数据范围 | 符合筛选条件的**全部**打印任务行(上限 **5000** 条;超出则 `UserFriendlyException`) |
+| 排序 | 与列表一致:`Sorting` 为 `PrintedAt asc` 时按打印时间升序,否则按打印时间降序 |
+| 响应 | `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,文件名示例 `print-log_yyyyMMdd-HHmmss.xlsx` |
+| Excel 列(工作表名 `Print Log`) | Label ID、Product Name、Category、Template、Printed At、Printed By、Location、Expiry Date(空值语义与列表「无」一致) |
+| 权限 | 与 `GetPrintLogListAsync` 相同:**admin** 可查全部;非 admin 仅导出本人打印记录 |
+
+**说明**:同模块另有 **`GET .../export-print-log-pdf`**(PDF);**Label Report** 页签仍使用 **`export-label-report-pdf`**。前端 `reportsService.exportPrintLogExcel` 已封装本接口。
+
+---
+
+## 附录 curl 模板
+
+将 `TOKEN` 替换为登录响应中的 `data.token` 整段;将 `BASE` 替换为实际基址(如 `http://localhost:19001`)。
+
+```bash
+# 登录
+curl -X POST "$BASE/api/oauth/Login" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "userName=admin&password=123456"
+
+# --- Location Manager ---
+curl -X GET "$BASE/api/app/location/download-location-import-template" \
+ -H "Authorization: TOKEN" \
+ -o "Location-Manager-template.xlsx"
+
+curl -X GET "$BASE/api/app/location/export-locations-excel?Partner=&GroupName=&State=&Keyword=&Sorting=" \
+ -H "Authorization: TOKEN" \
+ -o "locations-export.xlsx"
+
+curl -X POST "$BASE/api/app/location/import-locations-batch" \
+ -H "Authorization: TOKEN" \
+ -F "file=@./Location-Manager-template.xlsx"
+
+curl -X POST "$BASE/api/app/location/update-locations-bulk" \
+ -H "Authorization: TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{\"items\":[{\"id\":\"YOUR_LOCATION_ID\",\"locationName\":\"UNCC store\",\"state\":true}]}"
+
+# --- Team Member ---
+curl -X GET "$BASE/api/app/team-member/download-team-member-import-template" \
+ -H "Authorization: TOKEN" \
+ -o "Team-Member-template.xlsx"
+
+curl -X GET "$BASE/api/app/team-member/export-team-members-pdf?Keyword=&RoleId=&LocationId=&State=&Sorting=" \
+ -H "Authorization: TOKEN" \
+ -o "team-members.pdf"
+
+curl -X POST "$BASE/api/app/team-member/import-team-members-batch" \
+ -H "Authorization: TOKEN" \
+ -F "file=@./Team-Member-template.xlsx"
+
+curl -X POST "$BASE/api/app/team-member/update-team-members-bulk" \
+ -H "Authorization: TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{\"items\":[{\"id\":\"YOUR_USER_GUID\",\"fullName\":\"John\",\"userName\":\"john@example.com\",\"email\":\"john@example.com\",\"phone\":789654444,\"roleId\":\"ROLE_GUID\",\"locationIds\":[\"LOCATION_GUID\"],\"state\":true}]}"
+
+# --- Account Management:Company / Region(PDF 全量)---
+curl -X GET "$BASE/api/app/partner/export-pdf?Keyword=&State=&Sorting=" \
+ -H "Authorization: TOKEN" \
+ -o "companies-export.pdf"
+
+curl -X GET "$BASE/api/app/group/export-pdf?Keyword=&PartnerId=&State=&Sorting=" \
+ -H "Authorization: TOKEN" \
+ -o "regions-export.pdf"
+
+# --- Reports:Print Log(Excel 全量)---
+curl -X GET "$BASE/api/app/reports/export-print-log-excel?PartnerId=&GroupId=&LocationId=&StartDate=&EndDate=&Keyword=&Sorting=PrintedAt%20desc" \
+ -H "Authorization: TOKEN" \
+ -o "print-log-export.xlsx"
+
+# --- Products(菜单-产品)---
+curl -X GET "$BASE/api/app/product/download-product-import-template" \
+ -H "Authorization: TOKEN" \
+ -o "Product-Manager-template.xlsx"
+
+curl -X GET "$BASE/api/app/product/export-products-excel?Keyword=&State=&Sorting=" \
+ -H "Authorization: TOKEN" \
+ -o "products-export.xlsx"
+
+curl -X POST "$BASE/api/app/product/import-products-batch" \
+ -H "Authorization: TOKEN" \
+ -F "file=@./Product-Manager-template.xlsx"
+
+curl -X POST "$BASE/api/app/product/update-products-bulk" \
+ -H "Authorization: TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{\"items\":[{\"id\":\"YOUR_PRODUCT_ID\",\"productName\":\"Tuna & Bacon Sub\",\"categoryId\":\"CATEGORY_ID\",\"productCode\":\"40001\",\"state\":true,\"locationIds\":[\"LOCATION_GUID_1\",\"LOCATION_GUID_2\"]}]}"
+```
+
+更完整的接口测试流程见仓库内 `.codex/skills/api-interface-testing/SKILL.md`。
+
+---
+
+## 文档维护说明
+
+- **新增**某模块的导入/导出/下载模板/**批量编辑**接口时:在本文件 **目录** 表增加一行锚点,新增 **## n 模块名** 章节,并同步 **共享配置** 表(若有新配置项)。
+- **避免**在多个 Markdown 中重复粘贴大段相同表格;门店分页与单条接口仍以 `门店(Location)接口对接说明.md` 为准;本文侧重「批量」与「多行一次提交」类接口。
diff --git a/项目相关文档/门店(Location)接口对接说明.md b/项目相关文档/门店(Location)接口对接说明.md
index 9f6a918..9632d6e 100644
--- a/项目相关文档/门店(Location)接口对接说明.md
+++ b/项目相关文档/门店(Location)接口对接说明.md
@@ -7,6 +7,8 @@
门店模块的服务类为 `LocationAppService`(模块:`food-labeling-us`)。
> 说明:接口的最终 URL 以 Swagger 展示为准(在 Swagger 里搜索 `Location` 或 `LocationAppService` 即可)。
+>
+> **批量**(下载模板 / Excel 导出 / Excel 导入 / **JSON 批量编辑**)与后续其它模块同类接口的**统一汇总文档**见:`批量导入导出接口说明.md`(本文件「接口 3~5」与汇总文档中导入导出一致;**批量编辑**见汇总文档 **1.4**;后续新增批量类接口建议优先更新汇总文档)。
---
@@ -31,7 +33,7 @@
### 方法签名
-`Task> GetListAsync(LocationGetListInputVo input)`
+`Task> GetListAsync(LocationGetListInputVo input)`
### 入参(LocationGetListInputVo)
@@ -43,9 +45,12 @@
- `GroupName`:Group 精确过滤(可选)
- `State`:启用状态过滤(可选,true/false)
-### 出参(PagedResultDto)
+### 出参(PagedResultWithPageDto)
-- `TotalCount`:总数
+- `PageIndex`:当前页码(从 1 开始)
+- `PageSize`:每页条数
+- `TotalCount`:总条数
+- `TotalPages`:总页数
- `Items`:列表
`LocationGetListOutputDto` 字段:
@@ -92,10 +97,151 @@
---
+## 接口 3:下载批量导入模板(Excel 文件)
+
+从服务器配置的目录读取已部署的模板文件(如宝塔目录 `batchImportOfFiles` 下的 `Location-Manager-批量导入模板.xlsx`),以附件形式返回给浏览器/客户端。
+
+### 方法签名
+
+`Task DownloadLocationImportTemplateAsync()`
+
+### HTTP
+
+- **方法**:`GET`
+- **鉴权**:与其它 `LocationAppService` 接口一致(`Authorization` 携带登录返回的 `data.token` 完整值)
+
+### 常见路由(以 Swagger 为准)
+
+在 `RootPath = api/app`、约定式控制器默认命名规则下,一般为:
+
+- `GET /api/app/location/download-location-import-template`
+
+### 响应
+
+- **成功**:`Content-Type` 为 `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,`Content-Disposition` 带下载文件名(与服务器上模板文件名一致)
+- **失败**:业务异常提示(如未配置目录、模板文件不存在)
+
+### 相关配置(`appsettings`)
+
+配置节:`FoodLabeling:BatchImport`(类名 `FoodLabelingBatchImportOptions`)
+
+| 配置项 | 说明 |
+|--------|------|
+| `TemplateDirectory` | 模板所在目录绝对路径(生产示例:`/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles`) |
+| `LocationTemplateFileName` | Location 模板文件名(默认:`Location-Manager-批量导入模板.xlsx`) |
+
+---
+
+## 接口 4:批量导出门店(Excel)
+
+按与 **接口 1** 相同的筛选条件**全量**导出门店数据;导出为 `.xlsx`,列顺序与 Location Manager 表头及导入模板一致(含 `Latitude`、`Longitude`、`Active` 等)。**不按条数截断**;数据量大时占用内存与生成时间会增加。
+
+### 方法签名
+
+`Task ExportLocationsExcelAsync(LocationGetListInputVo input)`
+
+### HTTP
+
+- **方法**:`GET`
+- **Query**:筛选条件与接口 1 一致:`Sorting`、`Keyword`、`Partner`、`GroupName`、`State`。导出为**全量**(符合筛选的全部记录),**不使用**请求里的 `SkipCount` / `MaxResultCount`。
+
+### 常见路由(以 Swagger 为准)
+
+- `GET /api/app/location/export-locations-excel?Partner=...&GroupName=...&State=...&Keyword=...&Sorting=...`
+
+### 响应
+
+- **成功**:`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,文件名形如 `locations-export-yyyyMMdd-HHmmss.xlsx`
+- **数据范围**:在筛选结果上按 `Sorting` 或默认 `CreationTime` 降序导出**全部**行(无条数上限)
+
+### 相关配置
+
+门店导出**不再**使用条数上限配置;与导出体积相关的仅内存与 Excel 生成耗时。模板目录等仍见上文「相关配置(`appsettings`)」中 `FoodLabeling:BatchImport` 其它项。
+
+---
+
+## 接口 5:批量导入门店(Excel)
+
+上传 `.xlsx`,按行解析后逐行调用与 **接口 2** 相同的新增逻辑;单行失败不影响其它行处理,最终在 JSON 中返回成功数、失败数及错误明细。
+
+### 方法签名
+
+`Task ImportLocationsBatchAsync(LocationBatchImportInputVo input)`
+
+### HTTP
+
+- **方法**:`POST`
+- **Content-Type**:`multipart/form-data`
+- **表单字段**:`file`(类型:文件,扩展名必须为 `.xlsx`)
+
+### 常见路由(以 Swagger 为准)
+
+- `POST /api/app/location/import-locations-batch`
+
+### 入参(表单)
+
+- `file`:Excel 文件(必填)
+
+### 出参(LocationBatchImportResultDto)
+
+- `SuccessCount`:成功新增条数
+- `FailCount`:失败条数(含解析阶段与逐行 `CreateAsync` 业务校验失败)
+- `SkippedEmptyRows`:预留字段,当前实现一般为 `0`
+- `Errors`:错误列表(`LocationBatchImportErrorDto`)
+ - `RowNumber`:Excel 行号(表头为第 1 行,数据从第 2 行起;解析类错误可能为 `0`)
+ - `LocationCode`:该行 Location ID(若有)
+ - `Message`:错误说明
+
+### 解析与业务规则摘要
+
+- 表头需能识别 **Location ID** 列(或同义列,如 `LocationCode`);建议使用服务器提供的官方模板。
+- **必填**:`Location ID`(`LocationCode`)、`Location Name`(与单条新增接口一致)。
+- **可选**:Company/Region、地址、电话、邮箱、经纬度、`Active` 等;经纬度有值时校验格式,空则按 `null` 入库。
+- 单次处理行数上限:`MaxImportRows`(默认 5000);单文件大小上限:`MaxUploadBytes`(默认 10MB)。
+
+### 相关配置
+
+| 配置项 | 说明 |
+|--------|------|
+| `MaxImportRows` | 单次导入最多数据行数 |
+| `MaxUploadBytes` | 上传文件最大字节数 |
+
+---
+
+## curl 示例(本地 `http://localhost:19001`)
+
+请先按项目规范登录获取 `data.token`(见 `项目相关文档` 或 `.codex/skills/api-interface-testing`),以下用环境变量占位:
+
+```bash
+# 登录(示例账号以环境为准)
+curl -X POST "http://localhost:19001/api/oauth/Login" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "userName=admin&password=123456"
+
+# 下载模板(将 TOKEN 替换为响应中的 data.token 整段)
+curl -X GET "http://localhost:19001/api/app/location/download-location-import-template" \
+ -H "Authorization: TOKEN" \
+ -o "Location-Manager-template.xlsx"
+
+# 导出(带筛选示例,可按需删参)
+curl -X GET "http://localhost:19001/api/app/location/export-locations-excel?Partner=MedVantage%20Cafe%20Group&Keyword=" \
+ -H "Authorization: TOKEN" \
+ -o "locations-export.xlsx"
+
+# 批量导入(字段名必须为 file)
+curl -X POST "http://localhost:19001/api/app/location/import-locations-batch" \
+ -H "Authorization: TOKEN" \
+ -F "file=@./Location-Manager-template.xlsx"
+```
+
+若实际路径与上表不一致,**以 Swagger「食品标签-美国版接口」中 `LocationAppService` 展示的路径为准**。
+
+---
+
## Swagger 中如何找到
1. 启动后端宿主(`Yi.Abp.Web`),确保端口为 `19001`
2. 打开 `http://localhost:19001/swagger`
-3. 在接口分组里找到 **“食品标签-美国版接口”** 或直接搜索 `Location`
-4. 查看 `LocationAppService` 的 `GetListAsync` 与 `CreateAsync`
+3. 在接口分组里找到 **「食品标签-美国版接口」** 或直接搜索 `Location`
+4. 查看 `LocationAppService`:`GetListAsync`、`CreateAsync`、`UpdateAsync`、`DeleteAsync`,以及 **`DownloadLocationImportTemplateAsync`、`ExportLocationsExcelAsync`、`ImportLocationsBatchAsync`**