Commit 628891b0f08bf392891a02a47f0974c80ddd7da0

Authored by 杨鑫
2 parents 63289723 a7684ddb

合并

Showing 46 changed files with 3356 additions and 262 deletions
美国版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
... ... @@ -159,7 +159,7 @@
159 159 <view class="food-grid">
160 160 <view
161 161 v-for="product in pCat.products"
162   - :key="product.productId"
  162 + :key="productCardKey(product)"
163 163 class="food-card"
164 164 @click="handleProductClick(product, pCat.name)"
165 165 >
... ... @@ -408,8 +408,16 @@ function productPhotoSrc(p: UsAppLabelingProductNodeDto): string {
408 408 return resolveMediaUrlForApp(p.productImageUrl)
409 409 }
410 410  
  411 +/** 同一 productId 多模板拆卡时保证列表 :key 唯一 */
  412 +function productCardKey(p: UsAppLabelingProductNodeDto): string {
  413 + const tid = (p.templateId ?? '').trim()
  414 + return tid ? `${p.productId}|${tid}` : p.productId
  415 +}
  416 +
411 417 /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */
412 418 function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string {
  419 + const direct = (p.templateLabelSizeText ?? '').trim()
  420 + if (direct) return direct
413 421 const types = p.labelTypes || []
414 422 if (types.length === 0) return '—'
415 423 const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean)
... ...
美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
... ... @@ -40,6 +40,11 @@ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeD
40 40 }))
41 41 return {
42 42 productId: String(x?.productId ?? x?.ProductId ?? ''),
  43 + templateId: (x?.templateId ?? x?.TemplateId ?? null) as string | null,
  44 + templateCode: (x?.templateCode ?? x?.TemplateCode ?? null) as string | null,
  45 + templateLabelSizeText: (x?.templateLabelSizeText ?? x?.TemplateLabelSizeText ?? null) as
  46 + | string
  47 + | null,
43 48 productName: String(x?.productName ?? x?.ProductName ?? ''),
44 49 productCode: String(x?.productCode ?? x?.ProductCode ?? ''),
45 50 productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null,
... ...
美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
... ... @@ -11,6 +11,11 @@ export interface UsAppLabelTypeNodeDto {
11 11  
12 12 export interface UsAppLabelingProductNodeDto {
13 13 productId: string
  14 + /** 与 productId 组合唯一标识一张卡(多模板拆卡) */
  15 + templateId?: string | null
  16 + templateCode?: string | null
  17 + /** 当前卡片模板尺寸,与接口 templateLabelSizeText 对齐 */
  18 + templateLabelSizeText?: string | null
14 19 productName: string
15 20 productCode: string
16 21 productImageUrl: string | null
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBatchImportInputVo.cs 0 → 100644
  1 +using Microsoft.AspNetCore.Http;
  2 +using Microsoft.AspNetCore.Mvc;
  3 +
  4 +namespace FoodLabeling.Application.Contracts.Dtos.Location;
  5 +
  6 +/// <summary>
  7 +/// Location Manager 批量导入(Excel)
  8 +/// </summary>
  9 +public class LocationBatchImportInputVo
  10 +{
  11 + /// <summary>
  12 + /// 上传的 Excel 文件(与模板列一致)
  13 + /// </summary>
  14 + [FromForm(Name = "file")]
  15 + public IFormFile File { get; set; } = default!;
  16 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBatchImportResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Location;
  2 +
  3 +/// <summary>
  4 +/// Location 批量导入结果
  5 +/// </summary>
  6 +public class LocationBatchImportResultDto
  7 +{
  8 + public int SuccessCount { get; set; }
  9 +
  10 + public int FailCount { get; set; }
  11 +
  12 + public int SkippedEmptyRows { get; set; }
  13 +
  14 + public List<LocationBatchImportErrorDto> Errors { get; set; } = new();
  15 +}
  16 +
  17 +public class LocationBatchImportErrorDto
  18 +{
  19 + public int RowNumber { get; set; }
  20 +
  21 + public string? LocationCode { get; set; }
  22 +
  23 + public string Message { get; set; } = string.Empty;
  24 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBulkUpdateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Location;
  2 +
  3 +/// <summary>
  4 +/// 门店批量编辑(网格「保存全部」)请求体
  5 +/// </summary>
  6 +public class LocationBulkUpdateInputVo
  7 +{
  8 + /// <summary>
  9 + /// 待保存的行;<c>id</c> 为 <see cref="LocationGetListOutputDto.Id"/>。可含空行:忽略 <see cref="Guid.Empty"/> 的项。
  10 + /// </summary>
  11 + public List<LocationBulkUpdateItemVo> Items { get; set; } = new();
  12 +}
  13 +
  14 +/// <summary>
  15 +/// 单行编辑数据:主键 + 与单条 <c>PUT /location/{id}</c> 相同的可编辑字段。
  16 +/// </summary>
  17 +public class LocationBulkUpdateItemVo : LocationUpdateInputVo
  18 +{
  19 + /// <summary>
  20 + /// 门店主键(非空、非 Empty 时才会更新)
  21 + /// </summary>
  22 + public Guid Id { get; set; }
  23 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBulkUpdateResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Location;
  2 +
  3 +/// <summary>
  4 +/// 门店批量编辑结果
  5 +/// </summary>
  6 +public class LocationBulkUpdateResultDto
  7 +{
  8 + public int SuccessCount { get; set; }
  9 +
  10 + public int FailCount { get; set; }
  11 +
  12 + public List<LocationBulkUpdateErrorDto> Errors { get; set; } = new();
  13 +}
  14 +
  15 +/// <summary>
  16 +/// 批量编辑中单条失败信息
  17 +/// </summary>
  18 +public class LocationBulkUpdateErrorDto
  19 +{
  20 + /// <summary>
  21 + /// 在请求 <c>items</c> 数组中的序号(从 1 开始,与前端网格行对应)
  22 + /// </summary>
  23 + public int RowNumber { get; set; }
  24 +
  25 + public Guid Id { get; set; }
  26 +
  27 + public string Message { get; set; } = string.Empty;
  28 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportInputVo.cs 0 → 100644
  1 +using Microsoft.AspNetCore.Http;
  2 +using Microsoft.AspNetCore.Mvc;
  3 +
  4 +namespace FoodLabeling.Application.Contracts.Dtos.Product;
  5 +
  6 +/// <summary>
  7 +/// Product 批量导入(Excel)
  8 +/// </summary>
  9 +public class ProductBatchImportInputVo
  10 +{
  11 + /// <summary>
  12 + /// 上传的 Excel(列:Location / Product Category / Product / Product Code)
  13 + /// </summary>
  14 + [FromForm(Name = "file")]
  15 + public IFormFile File { get; set; } = default!;
  16 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Product;
  2 +
  3 +public class ProductBatchImportResultDto
  4 +{
  5 + public int SuccessCount { get; set; }
  6 +
  7 + public int FailCount { get; set; }
  8 +
  9 + public List<ProductBatchImportErrorDto> Errors { get; set; } = new();
  10 +}
  11 +
  12 +public class ProductBatchImportErrorDto
  13 +{
  14 + public int RowNumber { get; set; }
  15 +
  16 + public string? ProductName { get; set; }
  17 +
  18 + public string Message { get; set; } = string.Empty;
  19 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBulkUpdateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Product;
  2 +
  3 +/// <summary>
  4 +/// Product 批量编辑(网格保存全部)
  5 +/// </summary>
  6 +public class ProductBulkUpdateInputVo
  7 +{
  8 + public List<ProductBulkUpdateItemVo> Items { get; set; } = new();
  9 +}
  10 +
  11 +/// <summary>
  12 +/// 单行:产品主键(字符串 Guid)+ 与单条 PUT 相同的 body 字段。
  13 +/// </summary>
  14 +public class ProductBulkUpdateItemVo : ProductUpdateInputVo
  15 +{
  16 + public string Id { get; set; } = string.Empty;
  17 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBulkUpdateResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Product;
  2 +
  3 +public class ProductBulkUpdateResultDto
  4 +{
  5 + public int SuccessCount { get; set; }
  6 +
  7 + public int FailCount { get; set; }
  8 +
  9 + public List<ProductBulkUpdateErrorDto> Errors { get; set; } = new();
  10 +}
  11 +
  12 +public class ProductBulkUpdateErrorDto
  13 +{
  14 + public int RowNumber { get; set; }
  15 +
  16 + public string Id { get; set; } = string.Empty;
  17 +
  18 + public string Message { get; set; } = string.Empty;
  19 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs
... ... @@ -4,7 +4,10 @@ namespace FoodLabeling.Application.Contracts.Dtos.Product;
4 4  
5 5 public class ProductCreateInputVo
6 6 {
7   - public string ProductCode { get; set; } = string.Empty;
  7 + /// <summary>
  8 + /// 可选。不传或空则创建时由后端生成唯一编码(如 PRD_xxxxxxxx)。
  9 + /// </summary>
  10 + public string? ProductCode { get; set; }
8 11  
9 12 public string ProductName { get; set; } = string.Empty;
10 13  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportInputVo.cs 0 → 100644
  1 +using Microsoft.AspNetCore.Http;
  2 +using Microsoft.AspNetCore.Mvc;
  3 +
  4 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  5 +
  6 +/// <summary>
  7 +/// Team Member 批量导入(Excel)
  8 +/// </summary>
  9 +public class TeamMemberBatchImportInputVo
  10 +{
  11 + /// <summary>
  12 + /// 上传的 Excel 文件(与模板列一致)
  13 + /// </summary>
  14 + [FromForm(Name = "file")]
  15 + public IFormFile File { get; set; } = default!;
  16 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +/// <summary>
  4 +/// Team Member 批量导入结果
  5 +/// </summary>
  6 +public class TeamMemberBatchImportResultDto
  7 +{
  8 + public int SuccessCount { get; set; }
  9 +
  10 + public int FailCount { get; set; }
  11 +
  12 + public List<TeamMemberBatchImportErrorDto> Errors { get; set; } = new();
  13 +}
  14 +
  15 +public class TeamMemberBatchImportErrorDto
  16 +{
  17 + public int RowNumber { get; set; }
  18 +
  19 + public string? UserName { get; set; }
  20 +
  21 + public string Message { get; set; } = string.Empty;
  22 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBulkUpdateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +/// <summary>
  4 +/// Team Member 批量编辑(网格「保存全部」)请求体
  5 +/// </summary>
  6 +public class TeamMemberBulkUpdateInputVo
  7 +{
  8 + /// <summary>
  9 + /// 待保存的行;<c>id</c> 为成员主键。可含空行:忽略 id 为全零 GUID 的项。
  10 + /// </summary>
  11 + public List<TeamMemberBulkUpdateItemVo> Items { get; set; } = new();
  12 +}
  13 +
  14 +/// <summary>
  15 +/// 单行:主键 + 与单条 <c>PUT</c> 更新相同的字段。
  16 +/// </summary>
  17 +public class TeamMemberBulkUpdateItemVo : TeamMemberUpdateInputVo
  18 +{
  19 + public Guid Id { get; set; }
  20 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBulkUpdateResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberBulkUpdateResultDto
  4 +{
  5 + public int SuccessCount { get; set; }
  6 +
  7 + public int FailCount { get; set; }
  8 +
  9 + public List<TeamMemberBulkUpdateErrorDto> Errors { get; set; } = new();
  10 +}
  11 +
  12 +public class TeamMemberBulkUpdateErrorDto
  13 +{
  14 + public int RowNumber { get; set; }
  15 +
  16 + public Guid Id { get; set; }
  17 +
  18 + public string Message { get; set; } = string.Empty;
  19 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelingProductNodeDto.cs
1 1 namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
2 2  
3 3 /// <summary>
4   -/// 第三级:产品
  4 +/// 第三级:产品卡片(同一产品 Id 若存在多套标签模板,按 <c>TemplateId</c> 拆成多条,便于端上多卡展示)
5 5 /// </summary>
6 6 public class UsAppLabelingProductNodeDto
7 7 {
8 8 public string ProductId { get; set; } = string.Empty;
9 9  
  10 + /// <summary>当前卡片对应 <c>fl_label.TemplateId</c>;与 <c>ProductId</c> 共同唯一标识一张卡</summary>
  11 + public string TemplateId { get; set; } = string.Empty;
  12 +
  13 + /// <summary>当前卡片所用模板编码(与四级节点一致)</summary>
  14 + public string? TemplateCode { get; set; }
  15 +
  16 + /// <summary>当前卡片模板尺寸文案(如 6.00x12.00cm)</summary>
  17 + public string? TemplateLabelSizeText { get; set; }
  18 +
10 19 public string ProductName { get; set; } = string.Empty;
11 20  
12 21 public string ProductCode { get; set; } = string.Empty;
... ...
美国版/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
38 38 Task DeleteAsync(string id);
39 39  
40 40 /// <summary>
41   - /// 按列表相同筛选条件导出组织为 PDF(上限 5000 条)
  41 + /// 按列表相同筛选条件全量导出组织(Region)为 PDF(不分页;与 <see cref="GetListAsync"/> 相同筛选;单次最多 5000 条)
42 42 /// </summary>
  43 + /// <param name="input">Keyword、PartnerId、State、Sorting;分页字段忽略</param>
  44 + /// <returns>application/pdf</returns>
43 45 Task<IActionResult> ExportPdfAsync(GroupGetListInputVo input);
44 46 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILocationAppService.cs
1 1 using FoodLabeling.Application.Contracts.Dtos.Location;
2 2 using FoodLabeling.Application.Contracts.Dtos.Common;
  3 +using Microsoft.AspNetCore.Mvc;
3 4 using Volo.Abp.Application.Dtos;
4 5 using Volo.Abp.Application.Services;
5 6  
... ... @@ -34,5 +35,60 @@ public interface ILocationAppService : IApplicationService
34 35 /// </summary>
35 36 /// <param name="id">门店Id</param>
36 37 Task DeleteAsync(Guid id);
  38 +
  39 + /// <summary>
  40 + /// 下载 Location Manager 批量导入模板(读取服务器 <c>batchImportOfFiles</c> 目录下 xlsx)
  41 + /// </summary>
  42 + Task<IActionResult> DownloadLocationImportTemplateAsync();
  43 +
  44 + /// <summary>
  45 + /// 按列表筛选条件全量导出门店为 Excel(与列表相同过滤与排序,不分页、不限条数)
  46 + /// </summary>
  47 + Task<IActionResult> ExportLocationsExcelAsync(LocationGetListInputVo input);
  48 +
  49 + /// <summary>
  50 + /// 批量导入门店(Excel,multipart/form-data 字段 <c>file</c>)
  51 + /// </summary>
  52 + Task<LocationBatchImportResultDto> ImportLocationsBatchAsync(LocationBatchImportInputVo input);
  53 +
  54 + /// <summary>
  55 + /// 批量编辑门店(网格保存全部,JSON 一次提交多行)
  56 + /// </summary>
  57 + /// <remarks>
  58 + /// 每行通过 <c>id</c> 定位门店,字段与单条 <c>PUT /location/{id}</c> 一致;<c>id</c> 为 <see cref="Guid.Empty"/> 的项忽略。
  59 + ///
  60 + /// 示例请求:
  61 + /// ```json
  62 + /// {
  63 + /// "items": [
  64 + /// {
  65 + /// "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  66 + /// "partner": "MedVantage Cafe Group",
  67 + /// "groupName": "NC Region",
  68 + /// "locationName": "UNCC store",
  69 + /// "street": "222 School House Lane",
  70 + /// "city": "Charlotte",
  71 + /// "stateCode": "NC",
  72 + /// "country": "USA",
  73 + /// "zipCode": "29889",
  74 + /// "phone": "2123456789",
  75 + /// "email": "nc@example.com",
  76 + /// "latitude": 35.3,
  77 + /// "longitude": -80.7,
  78 + /// "state": true
  79 + /// }
  80 + /// ]
  81 + /// }
  82 + /// ```
  83 + ///
  84 + /// 参数说明:
  85 + /// - items: 编辑行数组;单行失败不影响其它行提交结果汇总
  86 + /// </remarks>
  87 + /// <param name="input">批量编辑请求体</param>
  88 + /// <returns>成功数、失败数及失败明细</returns>
  89 + /// <response code="200">全部或部分行处理完成,见返回体中的计数与 errors</response>
  90 + /// <response code="400">整单校验失败(如超过单次条数上限、items 为空)</response>
  91 + /// <response code="500">服务器错误</response>
  92 + Task<LocationBulkUpdateResultDto> UpdateLocationsBulkAsync(LocationBulkUpdateInputVo input);
37 93 }
38 94  
... ...
美国版/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
73 73 Task DeleteAsync(string id);
74 74  
75 75 /// <summary>
76   - /// 按当前列表筛选条件批量导出合作伙伴为 PDF(不分页,上限 5000 条)
  76 + /// 按当前列表筛选条件批量导出合作伙伴为 PDF(Account Management「Company」页签;不分页,上限 5000 条)
77 77 /// </summary>
78 78 /// <param name="input">与列表相同的 Keyword、State;分页字段忽略</param>
79 79 /// <returns>PDF 文件流</returns>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs
1 1 using FoodLabeling.Application.Contracts.Dtos.Common;
2 2 using FoodLabeling.Application.Contracts.Dtos.Product;
  3 +using Microsoft.AspNetCore.Mvc;
3 4 using Volo.Abp.Application.Dtos;
4 5 using Volo.Abp.Application.Services;
5 6  
... ... @@ -24,6 +25,7 @@ public interface IProductAppService : IApplicationService
24 25 /// 新增产品
25 26 /// </summary>
26 27 /// <remarks>
  28 + /// <see cref="ProductCreateInputVo.ProductCode"/> 可选;为空时后端生成唯一编码(如 PRD_ + Guid)。
27 29 /// 若 <see cref="ProductCreateInputVo.LocationIds"/> 有值,将在同一事务内批量写入 fl_location_product(一门店一条)。
28 30 /// </remarks>
29 31 Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input);
... ... @@ -41,5 +43,25 @@ public interface IProductAppService : IApplicationService
41 43 /// 删除产品(逻辑删除)
42 44 /// </summary>
43 45 Task DeleteAsync(string id);
  46 +
  47 + /// <summary>
  48 + /// 下载 Product 批量导入模板(服务器 <c>TemplateDirectory</c> 下 xlsx)
  49 + /// </summary>
  50 + Task<IActionResult> DownloadProductImportTemplateAsync();
  51 +
  52 + /// <summary>
  53 + /// 按列表筛选条件全量导出产品为 Excel(与列表相同过滤;不分页)
  54 + /// </summary>
  55 + Task<IActionResult> ExportProductsExcelAsync(ProductGetListInputVo input);
  56 +
  57 + /// <summary>
  58 + /// 批量导入产品(Excel,<c>multipart/form-data</c> 字段 <c>file</c>)
  59 + /// </summary>
  60 + Task<ProductBatchImportResultDto> ImportProductsBatchAsync(ProductBatchImportInputVo input);
  61 +
  62 + /// <summary>
  63 + /// 批量编辑产品(JSON 一次提交多行,与单条 <c>PUT</c> 字段一致)
  64 + /// </summary>
  65 + Task<ProductBulkUpdateResultDto> UpdateProductsBulkAsync(ProductBulkUpdateInputVo input);
44 66 }
45 67  
... ...
美国版/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
22 22 Task<IActionResult> ExportPrintLogPdfAsync(ReportsPrintLogGetListInputVo input);
23 23  
24 24 /// <summary>
  25 + /// Print Log 全量导出 Excel(筛选与列表一致;排序与列表 <c>PrintedAt</c> 规则一致;最多 5000 条)
  26 + /// </summary>
  27 + Task<IActionResult> ExportPrintLogExcelAsync(ReportsPrintLogGetListInputVo input);
  28 +
  29 + /// <summary>
25 30 /// 根据历史任务重打(与 App 入参一致);<c>admin</c> 可重打任意用户任务,否则仅本人任务。
26 31 /// </summary>
27 32 Task<UsAppLabelPrintOutputDto> ReprintPrintLogAsync(UsAppLabelReprintInputVo input);
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs
1 1 using FoodLabeling.Application.Contracts.Dtos.Common;
2 2 using FoodLabeling.Application.Contracts.Dtos.TeamMember;
  3 +using Microsoft.AspNetCore.Mvc;
3 4  
4 5 namespace FoodLabeling.Application.Contracts.IServices;
5 6  
... ... @@ -14,5 +15,24 @@ public interface ITeamMemberAppService
14 15 Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input);
15 16  
16 17 Task DeleteAsync(Guid id);
17   -}
18 18  
  19 + /// <summary>
  20 + /// 下载 Team Member 批量导入模板(服务器 batchImportOfFiles 目录下 xlsx)
  21 + /// </summary>
  22 + Task<IActionResult> DownloadTeamMemberImportTemplateAsync();
  23 +
  24 + /// <summary>
  25 + /// 按列表筛选条件全量导出成员为 PDF(与列表相同过滤;不分页、不限条数)
  26 + /// </summary>
  27 + Task<IActionResult> ExportTeamMembersPdfAsync(TeamMemberGetListInputVo input);
  28 +
  29 + /// <summary>
  30 + /// 批量导入成员(Excel,multipart/form-data 字段 <c>file</c>)
  31 + /// </summary>
  32 + Task<TeamMemberBatchImportResultDto> ImportTeamMembersBatchAsync(TeamMemberBatchImportInputVo input);
  33 +
  34 + /// <summary>
  35 + /// 批量编辑成员(JSON 一次提交多行)
  36 + /// </summary>
  37 + Task<TeamMemberBulkUpdateResultDto> UpdateTeamMembersBulkAsync(TeamMemberBulkUpdateInputVo input);
  38 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppLabelingAppService.cs
... ... @@ -5,7 +5,7 @@ using Volo.Abp.Application.Services;
5 5 namespace FoodLabeling.Application.Contracts.IServices;
6 6  
7 7 /// <summary>
8   -/// App Labeling:四级列表(标签分类 → 产品分类 → 产品 → 标签种类)
  8 +/// App Labeling:四级列表(标签分类 → 产品分类 → 产品卡片「按模板拆分」→ 标签种类)
9 9 /// </summary>
10 10 public interface IUsAppLabelingAppService : IApplicationService
11 11 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj
... ... @@ -2,6 +2,7 @@
2 2 <Import Project="..\..\..\common.props" />
3 3  
4 4 <ItemGroup>
  5 + <PackageReference Include="ClosedXML" Version="0.102.3" />
5 6 <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" />
6 7 <PackageReference Include="QuestPDF" Version="2024.12.2" />
7 8 </ItemGroup>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs
1 1 using FoodLabeling.Application.Contracts;
  2 +using FoodLabeling.Application.Options;
2 3 using FoodLabeling.Domain;
  4 +using Microsoft.Extensions.DependencyInjection;
  5 +using Volo.Abp.Modularity;
3 6 using Yi.Framework.Ddd.Application;
4 7  
5 8 namespace FoodLabeling.Application;
... ... @@ -14,5 +17,10 @@ namespace FoodLabeling.Application;
14 17 )]
15 18 public class FoodLabelingApplicationModule : AbpModule
16 19 {
  20 + public override void ConfigureServices(ServiceConfigurationContext context)
  21 + {
  22 + Configure<FoodLabelingBatchImportOptions>(
  23 + context.Services.GetConfiguration().GetSection(FoodLabelingBatchImportOptions.SectionName));
  24 + }
17 25 }
18 26  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationBatchExcelHelper.cs 0 → 100644
  1 +using System.Globalization;
  2 +using ClosedXML.Excel;
  3 +using FoodLabeling.Application.Contracts.Dtos.Location;
  4 +
  5 +namespace FoodLabeling.Application.Helpers;
  6 +
  7 +/// <summary>
  8 +/// Location 批量导入/导出 Excel(列名与 Web「Location Manager」表头对齐,兼容中英文表头别名)
  9 +/// </summary>
  10 +public static class LocationBatchExcelHelper
  11 +{
  12 + /// <summary>导出表头顺序(与模板一致)</summary>
  13 + public static readonly string[] ExportHeaders =
  14 + {
  15 + "Company", "Region", "Location ID", "Location Name", "Street", "City", "State", "Country",
  16 + "Zip Code", "Phone", "Email", "Latitude", "Longitude", "Active"
  17 + };
  18 +
  19 + /// <summary>
  20 + /// 将门店列表写入 xlsx 内存流。
  21 + /// </summary>
  22 + public static MemoryStream BuildExportWorkbook(IReadOnlyList<LocationGetListOutputDto> rows)
  23 + {
  24 + var ms = new MemoryStream();
  25 + using var wb = new XLWorkbook();
  26 + var ws = wb.AddWorksheet("Locations");
  27 + for (var i = 0; i < ExportHeaders.Length; i++)
  28 + {
  29 + ws.Cell(1, i + 1).Value = ExportHeaders[i];
  30 + ws.Cell(1, i + 1).Style.Font.Bold = true;
  31 + }
  32 +
  33 + var r = 2;
  34 + foreach (var x in rows)
  35 + {
  36 + ws.Cell(r, 1).Value = x.Partner ?? string.Empty;
  37 + ws.Cell(r, 2).Value = x.GroupName ?? string.Empty;
  38 + ws.Cell(r, 3).Value = x.LocationCode ?? string.Empty;
  39 + ws.Cell(r, 4).Value = x.LocationName ?? string.Empty;
  40 + ws.Cell(r, 5).Value = x.Street ?? string.Empty;
  41 + ws.Cell(r, 6).Value = x.City ?? string.Empty;
  42 + ws.Cell(r, 7).Value = x.StateCode ?? string.Empty;
  43 + ws.Cell(r, 8).Value = x.Country ?? string.Empty;
  44 + ws.Cell(r, 9).Value = x.ZipCode ?? string.Empty;
  45 + ws.Cell(r, 10).Value = x.Phone ?? string.Empty;
  46 + ws.Cell(r, 11).Value = x.Email ?? string.Empty;
  47 + ws.Cell(r, 12).Value = x.Latitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
  48 + ws.Cell(r, 13).Value = x.Longitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
  49 + ws.Cell(r, 14).Value = x.State ? "TRUE" : "FALSE";
  50 + r++;
  51 + }
  52 +
  53 + ws.Columns().AdjustToContents();
  54 + wb.SaveAs(ms);
  55 + ms.Position = 0;
  56 + return ms;
  57 + }
  58 +
  59 + /// <summary>
  60 + /// 从上传的 Excel 解析为创建入参列表(行号从 2 起为数据行)。
  61 + /// </summary>
  62 + public static List<(int RowNumber, LocationCreateInputVo Input)> ParseImportWorkbook(Stream stream,
  63 + int maxRows,
  64 + out List<LocationBatchImportErrorDto> parseErrors)
  65 + {
  66 + parseErrors = new List<LocationBatchImportErrorDto>();
  67 + var result = new List<(int, LocationCreateInputVo)>();
  68 +
  69 + using var wb = new XLWorkbook(stream);
  70 + var ws = wb.Worksheets.FirstOrDefault();
  71 + if (ws is null)
  72 + {
  73 + parseErrors.Add(new LocationBatchImportErrorDto
  74 + {
  75 + RowNumber = 0,
  76 + Message = "Excel 中无工作表"
  77 + });
  78 + return result;
  79 + }
  80 +
  81 + var headerRow = ws.Row(1);
  82 + if (!headerRow.CellsUsed().Any())
  83 + {
  84 + parseErrors.Add(new LocationBatchImportErrorDto { RowNumber = 1, Message = "表头为空" });
  85 + return result;
  86 + }
  87 +
  88 + var colMap = BuildHeaderColumnMap(headerRow);
  89 + if (!colMap.ContainsKey("locationcode"))
  90 + {
  91 + parseErrors.Add(new LocationBatchImportErrorDto
  92 + {
  93 + RowNumber = 1,
  94 + Message = "未找到「Location ID」列(或同义表头),请使用官方模板"
  95 + });
  96 + return result;
  97 + }
  98 +
  99 + var lastRow = ws.LastRowUsed()?.RowNumber() ?? 1;
  100 + var dataRowCount = 0;
  101 + for (var rowNum = 2; rowNum <= lastRow; rowNum++)
  102 + {
  103 + if (dataRowCount >= maxRows)
  104 + {
  105 + parseErrors.Add(new LocationBatchImportErrorDto
  106 + {
  107 + RowNumber = rowNum,
  108 + Message = $"已超过单次导入上限 {maxRows} 行,后续行已忽略"
  109 + });
  110 + break;
  111 + }
  112 +
  113 + var locCode = GetCellByField(colMap, ws, rowNum, "locationcode");
  114 + if (string.IsNullOrWhiteSpace(locCode) && IsRowEmpty(colMap, ws, rowNum))
  115 + {
  116 + continue;
  117 + }
  118 +
  119 + dataRowCount++;
  120 + var errPrefix = $"第 {rowNum} 行";
  121 + try
  122 + {
  123 + var input = BuildCreateInputFromRow(colMap, ws, rowNum, out var rowErrs);
  124 + if (rowErrs.Count > 0)
  125 + {
  126 + foreach (var e in rowErrs)
  127 + {
  128 + parseErrors.Add(new LocationBatchImportErrorDto
  129 + {
  130 + RowNumber = rowNum,
  131 + LocationCode = locCode,
  132 + Message = $"{errPrefix}:{e}"
  133 + });
  134 + }
  135 +
  136 + continue;
  137 + }
  138 +
  139 + result.Add((rowNum, input!));
  140 + }
  141 + catch (Exception ex)
  142 + {
  143 + parseErrors.Add(new LocationBatchImportErrorDto
  144 + {
  145 + RowNumber = rowNum,
  146 + LocationCode = locCode,
  147 + Message = $"{errPrefix}:{ex.Message}"
  148 + });
  149 + }
  150 + }
  151 +
  152 + return result;
  153 + }
  154 +
  155 + private static Dictionary<string, int> BuildHeaderColumnMap(IXLRow headerRow)
  156 + {
  157 + var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
  158 + foreach (var cell in headerRow.CellsUsed())
  159 + {
  160 + var key = NormalizeHeaderKey(cell.GetString());
  161 + if (string.IsNullOrEmpty(key))
  162 + {
  163 + continue;
  164 + }
  165 +
  166 + var field = MapHeaderToField(key);
  167 + if (field is null)
  168 + {
  169 + continue;
  170 + }
  171 +
  172 + if (!map.ContainsKey(field))
  173 + {
  174 + map[field] = cell.Address.ColumnNumber;
  175 + }
  176 + }
  177 +
  178 + return map;
  179 + }
  180 +
  181 + private static string? MapHeaderToField(string normalizedHeader)
  182 + {
  183 + return normalizedHeader switch
  184 + {
  185 + "company" or "partner" or "合作伙伴" or "公司" => "partner",
  186 + "region" or "groupname" or "group" or "区域" or "组织" => "groupname",
  187 + "locationid" or "locationcode" or "门店编码" or "门店id" => "locationcode",
  188 + "locationname" or "门店名称" or "name" => "locationname",
  189 + "street" or "地址" => "street",
  190 + "city" or "城市" => "city",
  191 + "state" or "statecode" or "省州" => "statecode",
  192 + "country" or "国家" => "country",
  193 + "zipcode" or "zip" or "邮编" or "邮政编码" => "zipcode",
  194 + "phone" or "电话" or "手机" => "phone",
  195 + "email" or "邮箱" => "email",
  196 + "latitude" or "lat" or "纬度" => "latitude",
  197 + "longitude" or "lng" or "lon" or "经度" => "longitude",
  198 + "active" or "启用" or "状态" or "isactive" => "active",
  199 + _ => null
  200 + };
  201 + }
  202 +
  203 + private static string NormalizeHeaderKey(string raw)
  204 + {
  205 + var s = raw.Trim();
  206 + if (s.Length > 0 && s[0] == '\uFEFF')
  207 + {
  208 + s = s.TrimStart('\uFEFF');
  209 + }
  210 +
  211 + s = s.Trim();
  212 + return string.Concat(s.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
  213 + }
  214 +
  215 + private static bool IsRowEmpty(Dictionary<string, int> colMap, IXLWorksheet ws, int rowNum)
  216 + {
  217 + foreach (var col in colMap.Values)
  218 + {
  219 + var t = ws.Cell(rowNum, col).GetString().Trim();
  220 + if (!string.IsNullOrEmpty(t))
  221 + {
  222 + return false;
  223 + }
  224 + }
  225 +
  226 + return true;
  227 + }
  228 +
  229 + private static string GetCellByField(Dictionary<string, int> colMap, IXLWorksheet ws, int row, string field)
  230 + {
  231 + if (!colMap.TryGetValue(field, out var col))
  232 + {
  233 + return string.Empty;
  234 + }
  235 +
  236 + return ws.Cell(row, col).GetString().Trim();
  237 + }
  238 +
  239 + private static LocationCreateInputVo? BuildCreateInputFromRow(Dictionary<string, int> colMap, IXLWorksheet ws,
  240 + int rowNum, out List<string> errors)
  241 + {
  242 + errors = new List<string>();
  243 + var partner = GetCellByField(colMap, ws, rowNum, "partner");
  244 + var groupName = GetCellByField(colMap, ws, rowNum, "groupname");
  245 + var locationCode = GetCellByField(colMap, ws, rowNum, "locationcode");
  246 + var locationName = GetCellByField(colMap, ws, rowNum, "locationname");
  247 + var street = GetCellByField(colMap, ws, rowNum, "street");
  248 + var city = GetCellByField(colMap, ws, rowNum, "city");
  249 + var stateCode = GetCellByField(colMap, ws, rowNum, "statecode");
  250 + var country = GetCellByField(colMap, ws, rowNum, "country");
  251 + var zipCode = GetCellByField(colMap, ws, rowNum, "zipcode");
  252 + var phone = GetCellByField(colMap, ws, rowNum, "phone");
  253 + var email = GetCellByField(colMap, ws, rowNum, "email");
  254 + var latStr = GetCellByField(colMap, ws, rowNum, "latitude");
  255 + var lngStr = GetCellByField(colMap, ws, rowNum, "longitude");
  256 + var activeStr = GetCellByField(colMap, ws, rowNum, "active");
  257 +
  258 + if (string.IsNullOrWhiteSpace(locationCode))
  259 + {
  260 + errors.Add("Location ID 不能为空");
  261 + }
  262 +
  263 + if (string.IsNullOrWhiteSpace(locationName))
  264 + {
  265 + errors.Add("Location Name 不能为空");
  266 + }
  267 +
  268 + decimal? lat = null;
  269 + if (!string.IsNullOrWhiteSpace(latStr))
  270 + {
  271 + if (!decimal.TryParse(latStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var latVal))
  272 + {
  273 + errors.Add("Latitude 格式不正确");
  274 + }
  275 + else
  276 + {
  277 + lat = latVal;
  278 + }
  279 + }
  280 +
  281 + decimal? lng = null;
  282 + if (!string.IsNullOrWhiteSpace(lngStr))
  283 + {
  284 + if (!decimal.TryParse(lngStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var lngVal))
  285 + {
  286 + errors.Add("Longitude 格式不正确");
  287 + }
  288 + else
  289 + {
  290 + lng = lngVal;
  291 + }
  292 + }
  293 +
  294 + if (errors.Count > 0)
  295 + {
  296 + return null;
  297 + }
  298 +
  299 + var state = ParseBool(activeStr, defaultValue: true);
  300 + return new LocationCreateInputVo
  301 + {
  302 + Partner = NullIfEmpty(partner),
  303 + GroupName = NullIfEmpty(groupName),
  304 + LocationCode = locationCode,
  305 + LocationName = locationName,
  306 + Street = NullIfEmpty(street),
  307 + City = NullIfEmpty(city),
  308 + StateCode = NullIfEmpty(stateCode),
  309 + Country = NullIfEmpty(country),
  310 + ZipCode = NullIfEmpty(zipCode),
  311 + Phone = NullIfEmpty(phone),
  312 + Email = NullIfEmpty(email),
  313 + Latitude = lat,
  314 + Longitude = lng,
  315 + State = state
  316 + };
  317 + }
  318 +
  319 + private static string? NullIfEmpty(string s)
  320 + {
  321 + var t = s.Trim();
  322 + return string.IsNullOrEmpty(t) ? null : t;
  323 + }
  324 +
  325 + private static bool ParseBool(string? raw, bool defaultValue)
  326 + {
  327 + if (string.IsNullOrWhiteSpace(raw))
  328 + {
  329 + return defaultValue;
  330 + }
  331 +
  332 + var s = raw.Trim();
  333 + if (bool.TryParse(s, out var b))
  334 + {
  335 + return b;
  336 + }
  337 +
  338 + if (int.TryParse(s, out var n))
  339 + {
  340 + return n != 0;
  341 + }
  342 +
  343 + if (string.Equals(s, "是", StringComparison.Ordinal) ||
  344 + string.Equals(s, "Y", StringComparison.OrdinalIgnoreCase) ||
  345 + string.Equals(s, "Yes", StringComparison.OrdinalIgnoreCase))
  346 + {
  347 + return true;
  348 + }
  349 +
  350 + if (string.Equals(s, "否", StringComparison.Ordinal) ||
  351 + string.Equals(s, "N", StringComparison.OrdinalIgnoreCase) ||
  352 + string.Equals(s, "No", StringComparison.OrdinalIgnoreCase))
  353 + {
  354 + return false;
  355 + }
  356 +
  357 + return defaultValue;
  358 + }
  359 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ProductBatchExcelHelper.cs 0 → 100644
  1 +using ClosedXML.Excel;
  2 +using FoodLabeling.Application.Contracts.Dtos.Product;
  3 +
  4 +namespace FoodLabeling.Application.Helpers;
  5 +
  6 +/// <summary>
  7 +/// Product 批量导入/导出 Excel(列与「Product-Manager-批量导入模板」一致)
  8 +/// </summary>
  9 +public static class ProductBatchExcelHelper
  10 +{
  11 + /// <summary>导出表头顺序(与模板一致)</summary>
  12 + public static readonly string[] ExportHeaders =
  13 + {
  14 + "Location", "Product Category", "Product", "Product Code"
  15 + };
  16 +
  17 + /// <summary>导出数据行</summary>
  18 + public sealed record ExportRow(string LocationDisplay, string CategoryName, string ProductName, string ProductCode);
  19 +
  20 + /// <summary>
  21 + /// 将产品导出数据写入 xlsx 内存流(工作表名 <c>Products</c>)。
  22 + /// </summary>
  23 + public static MemoryStream BuildExportWorkbook(IReadOnlyList<ExportRow> rows)
  24 + {
  25 + var ms = new MemoryStream();
  26 + using var wb = new XLWorkbook();
  27 + var ws = wb.AddWorksheet("Products");
  28 + for (var i = 0; i < ExportHeaders.Length; i++)
  29 + {
  30 + ws.Cell(1, i + 1).Value = ExportHeaders[i];
  31 + ws.Cell(1, i + 1).Style.Font.Bold = true;
  32 + }
  33 +
  34 + var r = 2;
  35 + foreach (var x in rows)
  36 + {
  37 + ws.Cell(r, 1).Value = x.LocationDisplay ?? string.Empty;
  38 + ws.Cell(r, 2).Value = x.CategoryName ?? string.Empty;
  39 + ws.Cell(r, 3).Value = x.ProductName ?? string.Empty;
  40 + ws.Cell(r, 4).Value = x.ProductCode ?? string.Empty;
  41 + r++;
  42 + }
  43 +
  44 + ws.Columns().AdjustToContents();
  45 + wb.SaveAs(ms);
  46 + ms.Position = 0;
  47 + return ms;
  48 + }
  49 +
  50 + /// <summary>
  51 + /// 从上传的 Excel 解析为原始行(行号从 2 起为数据行)。
  52 + /// </summary>
  53 + public static List<(int RowNumber, string LocationCell, string CategoryName, string ProductName, string ProductCode)>
  54 + ParseImportWorkbook(Stream stream, int maxRows, out List<ProductBatchImportErrorDto> parseErrors)
  55 + {
  56 + parseErrors = new List<ProductBatchImportErrorDto>();
  57 + var result = new List<(int, string, string, string, string)>();
  58 +
  59 + using var wb = new XLWorkbook(stream);
  60 + var ws = wb.Worksheets.FirstOrDefault(w =>
  61 + string.Equals(w.Name, "Products", StringComparison.OrdinalIgnoreCase))
  62 + ?? wb.Worksheets.FirstOrDefault();
  63 + if (ws is null)
  64 + {
  65 + parseErrors.Add(new ProductBatchImportErrorDto { RowNumber = 0, Message = "Excel 中无工作表" });
  66 + return result;
  67 + }
  68 +
  69 + var headerRow = ws.Row(1);
  70 + if (!headerRow.CellsUsed().Any())
  71 + {
  72 + parseErrors.Add(new ProductBatchImportErrorDto { RowNumber = 1, Message = "表头为空" });
  73 + return result;
  74 + }
  75 +
  76 + var colMap = BuildHeaderColumnMap(headerRow);
  77 + if (!colMap.ContainsKey("product") || !colMap.ContainsKey("productcategory"))
  78 + {
  79 + parseErrors.Add(new ProductBatchImportErrorDto
  80 + {
  81 + RowNumber = 1,
  82 + Message = "未找到「Product」与「Product Category」列(或同义表头),请使用官方模板"
  83 + });
  84 + return result;
  85 + }
  86 +
  87 + var lastRow = ws.LastRowUsed()?.RowNumber() ?? 1;
  88 + var dataRowCount = 0;
  89 + for (var rowNum = 2; rowNum <= lastRow; rowNum++)
  90 + {
  91 + if (dataRowCount >= maxRows)
  92 + {
  93 + parseErrors.Add(new ProductBatchImportErrorDto
  94 + {
  95 + RowNumber = rowNum,
  96 + Message = $"已超过单次导入上限 {maxRows} 行,后续行已忽略"
  97 + });
  98 + break;
  99 + }
  100 +
  101 + var productName = GetCellText(colMap, ws, rowNum, "product");
  102 + var categoryName = GetCellText(colMap, ws, rowNum, "productcategory");
  103 + var locationCell = colMap.ContainsKey("location")
  104 + ? GetCellText(colMap, ws, rowNum, "location")
  105 + : string.Empty;
  106 + var productCode = colMap.ContainsKey("productcode")
  107 + ? GetCellText(colMap, ws, rowNum, "productcode")
  108 + : string.Empty;
  109 +
  110 + if (string.IsNullOrWhiteSpace(productName) && string.IsNullOrWhiteSpace(categoryName) &&
  111 + IsRowEmpty(colMap, ws, rowNum))
  112 + {
  113 + continue;
  114 + }
  115 +
  116 + dataRowCount++;
  117 + var errPrefix = $"第 {rowNum} 行";
  118 + var rowErrs = new List<string>();
  119 + if (string.IsNullOrWhiteSpace(productName))
  120 + {
  121 + rowErrs.Add("Product 不能为空");
  122 + }
  123 +
  124 + if (string.IsNullOrWhiteSpace(categoryName))
  125 + {
  126 + rowErrs.Add("Product Category 不能为空");
  127 + }
  128 +
  129 + if (rowErrs.Count > 0)
  130 + {
  131 + foreach (var e in rowErrs)
  132 + {
  133 + parseErrors.Add(new ProductBatchImportErrorDto
  134 + {
  135 + RowNumber = rowNum,
  136 + ProductName = productName,
  137 + Message = $"{errPrefix}:{e}"
  138 + });
  139 + }
  140 +
  141 + continue;
  142 + }
  143 +
  144 + result.Add((rowNum, locationCell, categoryName.Trim(), productName.Trim(), productCode.Trim()));
  145 + }
  146 +
  147 + return result;
  148 + }
  149 +
  150 + private static Dictionary<string, int> BuildHeaderColumnMap(IXLRow headerRow)
  151 + {
  152 + var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
  153 + foreach (var cell in headerRow.CellsUsed())
  154 + {
  155 + var key = NormalizeHeaderKey(cell.GetString());
  156 + if (string.IsNullOrEmpty(key))
  157 + {
  158 + continue;
  159 + }
  160 +
  161 + var field = MapHeaderToField(key);
  162 + if (field is null)
  163 + {
  164 + continue;
  165 + }
  166 +
  167 + if (!map.ContainsKey(field))
  168 + {
  169 + map[field] = cell.Address.ColumnNumber;
  170 + }
  171 + }
  172 +
  173 + return map;
  174 + }
  175 +
  176 + private static string? MapHeaderToField(string normalizedHeader)
  177 + {
  178 + return normalizedHeader switch
  179 + {
  180 + "location" or "locations" or "门店" or "分配门店" => "location",
  181 + "productcategory" or "category" or "产品分类" or "分类" => "productcategory",
  182 + "product" or "productname" or "产品" or "产品名称" => "product",
  183 + "productcode" or "code" or "产品编码" or "编码" => "productcode",
  184 + _ => null
  185 + };
  186 + }
  187 +
  188 + private static string NormalizeHeaderKey(string raw)
  189 + {
  190 + var s = raw.Trim();
  191 + if (s.Length > 0 && s[0] == '\uFEFF')
  192 + {
  193 + s = s.TrimStart('\uFEFF');
  194 + }
  195 +
  196 + s = s.Trim().TrimStart('*');
  197 + return string.Concat(s.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
  198 + }
  199 +
  200 + private static bool IsRowEmpty(Dictionary<string, int> colMap, IXLWorksheet ws, int rowNum)
  201 + {
  202 + foreach (var col in colMap.Values)
  203 + {
  204 + var t = GetCellRaw(ws, rowNum, col);
  205 + if (!string.IsNullOrEmpty(t))
  206 + {
  207 + return false;
  208 + }
  209 + }
  210 +
  211 + return true;
  212 + }
  213 +
  214 + private static string GetCellText(Dictionary<string, int> colMap, IXLWorksheet ws, int row, string field)
  215 + {
  216 + if (!colMap.TryGetValue(field, out var col))
  217 + {
  218 + return string.Empty;
  219 + }
  220 +
  221 + return GetCellRaw(ws, row, col).Trim();
  222 + }
  223 +
  224 + private static string GetCellRaw(IXLWorksheet ws, int row, int col)
  225 + {
  226 + var c = ws.Cell(row, col);
  227 + if (c.IsEmpty())
  228 + {
  229 + return string.Empty;
  230 + }
  231 +
  232 + var s = c.GetString().Trim();
  233 + if (!string.IsNullOrEmpty(s))
  234 + {
  235 + return s;
  236 + }
  237 +
  238 + if (c.Value.IsNumber)
  239 + {
  240 + return c.GetFormattedString().Trim();
  241 + }
  242 +
  243 + return c.Value.ToString()?.Trim() ?? string.Empty;
  244 + }
  245 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExcelHelper.cs 0 → 100644
  1 +using ClosedXML.Excel;
  2 +using FoodLabeling.Application.Contracts.Dtos.Reports;
  3 +
  4 +namespace FoodLabeling.Application.Helpers;
  5 +
  6 +/// <summary>
  7 +/// Reports — Print Log 全量导出 Excel(列与 Web Print Log 表头对齐)
  8 +/// </summary>
  9 +public static class ReportsPrintLogExcelHelper
  10 +{
  11 + /// <summary>导出表头(与 UI 一致)</summary>
  12 + public static readonly string[] Headers =
  13 + {
  14 + "Label ID", "Product Name", "Category", "Template", "Printed At", "Printed By", "Location", "Expiry Date"
  15 + };
  16 +
  17 + /// <summary>
  18 + /// 将 Print Log 行写入 xlsx 内存流(工作表名 <c>Print Log</c>)。
  19 + /// </summary>
  20 + public static MemoryStream BuildWorkbook(IReadOnlyList<ReportsPrintLogListItemDto> rows)
  21 + {
  22 + var ms = new MemoryStream();
  23 + using var wb = new XLWorkbook();
  24 + var ws = wb.AddWorksheet("Print Log");
  25 + for (var i = 0; i < Headers.Length; i++)
  26 + {
  27 + ws.Cell(1, i + 1).Value = Headers[i];
  28 + ws.Cell(1, i + 1).Style.Font.Bold = true;
  29 + }
  30 +
  31 + var r = 2;
  32 + foreach (var x in rows)
  33 + {
  34 + ws.Cell(r, 1).Value = x.LabelCode ?? string.Empty;
  35 + ws.Cell(r, 2).Value = x.ProductName ?? string.Empty;
  36 + ws.Cell(r, 3).Value = x.CategoryName ?? string.Empty;
  37 + ws.Cell(r, 4).Value = x.TemplateText ?? string.Empty;
  38 + ws.Cell(r, 5).Value = x.PrintedAt;
  39 + ws.Cell(r, 5).Style.DateFormat.Format = "yyyy-mm-dd hh:mm:ss";
  40 + ws.Cell(r, 6).Value = x.PrintedByName ?? string.Empty;
  41 + ws.Cell(r, 7).Value = x.LocationText ?? string.Empty;
  42 + ws.Cell(r, 8).Value = x.ExpiryDateText ?? string.Empty;
  43 + r++;
  44 + }
  45 +
  46 + ws.Columns().AdjustToContents();
  47 + wb.SaveAs(ms);
  48 + ms.Position = 0;
  49 + return ms;
  50 + }
  51 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs
1 1 using Volo.Abp.Users;
  2 +using Yi.Framework.Rbac.Domain.Shared.Consts;
2 3  
3 4 namespace FoodLabeling.Application.Helpers;
4 5  
5 6 /// <summary>
6   -/// Reports 模块角色判断(与 JWT / CurrentUser.Roles 中的角色码一致)
  7 +/// Reports 模块角色判断(与 JWT 中角色声明一致)
7 8 /// </summary>
8 9 public static class ReportsRoleHelper
9 10 {
10 11 /// <summary>
11   - /// 是否为管理员:任一角色码等于 <c>admin</c>(忽略大小写)则视为可查看全部打印数据。
  12 + /// 是否为「可查看全部用户打印数据」的管理员:
  13 + /// <list type="bullet">
  14 + /// <item>标准 <see cref="ICurrentUser.Roles"/> 中含角色码 <c>admin</c>(普通账号绑定 RoleCode=admin 时走此路径);</item>
  15 + /// <item>内置超管:用户名 <c>admin</c> 时 JWT 使用自定义 claim <c>Roles</c>,不写多条 <c>role</c>,需单独识别;</item>
  16 + /// <item>超管权限 claim <c>Permission</c> 为 <c>*:*:*</c> 时视为管理员。</item>
  17 + /// </list>
12 18 /// </summary>
13 19 public static bool IsAdminRole(ICurrentUser currentUser)
14 20 {
15   - if (currentUser.Roles is null)
  21 + if (currentUser.Id is null)
16 22 {
17 23 return false;
18 24 }
19 25  
20   - foreach (var r in currentUser.Roles)
  26 + var userName = currentUser.UserName?.Trim();
  27 + if (!string.IsNullOrWhiteSpace(userName) &&
  28 + string.Equals(userName, UserConst.Admin, StringComparison.OrdinalIgnoreCase))
21 29 {
22   - if (!string.IsNullOrWhiteSpace(r) &&
23   - string.Equals(r.Trim(), "admin", StringComparison.OrdinalIgnoreCase))
  30 + return true;
  31 + }
  32 +
  33 + foreach (var c in currentUser.FindClaims(TokenTypeConst.Permission))
  34 + {
  35 + if (!string.IsNullOrWhiteSpace(c.Value) &&
  36 + string.Equals(c.Value.Trim(), UserConst.AdminPermissionCode, StringComparison.Ordinal))
24 37 {
25 38 return true;
26 39 }
27 40 }
28 41  
  42 + var rolesClaim = currentUser.FindClaims(TokenTypeConst.Roles).Select(x => x.Value).FirstOrDefault();
  43 + if (!string.IsNullOrWhiteSpace(rolesClaim))
  44 + {
  45 + foreach (var part in rolesClaim.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
  46 + {
  47 + if (string.Equals(part, UserConst.AdminRolesCode, StringComparison.OrdinalIgnoreCase))
  48 + {
  49 + return true;
  50 + }
  51 + }
  52 + }
  53 +
  54 + if (currentUser.Roles is not null)
  55 + {
  56 + foreach (var r in currentUser.Roles)
  57 + {
  58 + if (!string.IsNullOrWhiteSpace(r) &&
  59 + string.Equals(r.Trim(), UserConst.AdminRolesCode, StringComparison.OrdinalIgnoreCase))
  60 + {
  61 + return true;
  62 + }
  63 + }
  64 + }
  65 +
29 66 return false;
30 67 }
31 68 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberBatchExcelHelper.cs 0 → 100644
  1 +using System.Globalization;
  2 +using ClosedXML.Excel;
  3 +using FoodLabeling.Application.Contracts.Dtos.TeamMember;
  4 +
  5 +namespace FoodLabeling.Application.Helpers;
  6 +
  7 +/// <summary>
  8 +/// Team Member 批量导入 Excel(列名与 Account Management 表格对齐,兼容常见别名)
  9 +/// </summary>
  10 +public static class TeamMemberBatchExcelHelper
  11 +{
  12 + /// <summary>
  13 + /// 从上传的 Excel 解析为创建入参列表(行号从 2 起为数据行)。
  14 + /// </summary>
  15 + /// <param name="stream">xlsx 流</param>
  16 + /// <param name="maxRows">最多数据行</param>
  17 + /// <param name="roleNameToId">角色名(忽略大小写、去空白)到角色 Id</param>
  18 + /// <param name="defaultPassword">未填 Password 列时使用</param>
  19 + /// <param name="parseErrors">表头或解析错误</param>
  20 + public static List<(int RowNumber, TeamMemberCreateInputVo Input)> ParseImportWorkbook(
  21 + Stream stream,
  22 + int maxRows,
  23 + IReadOnlyDictionary<string, Guid> roleNameToId,
  24 + string defaultPassword,
  25 + out List<TeamMemberBatchImportErrorDto> parseErrors)
  26 + {
  27 + parseErrors = new List<TeamMemberBatchImportErrorDto>();
  28 + var result = new List<(int, TeamMemberCreateInputVo)>();
  29 +
  30 + if (string.IsNullOrWhiteSpace(defaultPassword))
  31 + {
  32 + parseErrors.Add(new TeamMemberBatchImportErrorDto
  33 + {
  34 + RowNumber = 0,
  35 + Message = "未配置默认导入密码 FoodLabeling:BatchImport:TeamMemberImportDefaultPassword"
  36 + });
  37 + return result;
  38 + }
  39 +
  40 + using var wb = new XLWorkbook(stream);
  41 + var ws = wb.Worksheets.FirstOrDefault();
  42 + if (ws is null)
  43 + {
  44 + parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = 0, Message = "Excel 中无工作表" });
  45 + return result;
  46 + }
  47 +
  48 + var headerRow = ws.Row(1);
  49 + if (!headerRow.CellsUsed().Any())
  50 + {
  51 + parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = 1, Message = "表头为空" });
  52 + return result;
  53 + }
  54 +
  55 + var colMap = BuildHeaderColumnMap(headerRow);
  56 + if (!colMap.ContainsKey("fullname") || !colMap.ContainsKey("email"))
  57 + {
  58 + parseErrors.Add(new TeamMemberBatchImportErrorDto
  59 + {
  60 + RowNumber = 1,
  61 + Message = "未找到「Name」与「Email」列(或同义表头),请使用官方模板"
  62 + });
  63 + return result;
  64 + }
  65 +
  66 + var lastRow = ws.LastRowUsed()?.RowNumber() ?? 1;
  67 + var dataRowCount = 0;
  68 + for (var rowNum = 2; rowNum <= lastRow; rowNum++)
  69 + {
  70 + if (dataRowCount >= maxRows)
  71 + {
  72 + parseErrors.Add(new TeamMemberBatchImportErrorDto
  73 + {
  74 + RowNumber = rowNum,
  75 + Message = $"已超过单次导入上限 {maxRows} 行,后续行已忽略"
  76 + });
  77 + break;
  78 + }
  79 +
  80 + var fullName = GetCellByField(colMap, ws, rowNum, "fullname");
  81 + var email = GetCellByField(colMap, ws, rowNum, "email");
  82 + if (string.IsNullOrWhiteSpace(fullName) && string.IsNullOrWhiteSpace(email) && IsRowEmpty(colMap, ws, rowNum))
  83 + {
  84 + continue;
  85 + }
  86 +
  87 + dataRowCount++;
  88 + var errPrefix = $"第 {rowNum} 行";
  89 + try
  90 + {
  91 + var input = BuildCreateInputFromRow(
  92 + colMap,
  93 + ws,
  94 + rowNum,
  95 + roleNameToId,
  96 + defaultPassword,
  97 + out var rowErrs,
  98 + out var userNameHint);
  99 + if (rowErrs.Count > 0)
  100 + {
  101 + foreach (var e in rowErrs)
  102 + {
  103 + parseErrors.Add(new TeamMemberBatchImportErrorDto
  104 + {
  105 + RowNumber = rowNum,
  106 + UserName = userNameHint,
  107 + Message = $"{errPrefix}:{e}"
  108 + });
  109 + }
  110 +
  111 + continue;
  112 + }
  113 +
  114 + result.Add((rowNum, input!));
  115 + }
  116 + catch (Exception ex)
  117 + {
  118 + parseErrors.Add(new TeamMemberBatchImportErrorDto
  119 + {
  120 + RowNumber = rowNum,
  121 + Message = $"{errPrefix}:{ex.Message}"
  122 + });
  123 + }
  124 + }
  125 +
  126 + return result;
  127 + }
  128 +
  129 + private static Dictionary<string, int> BuildHeaderColumnMap(IXLRow headerRow)
  130 + {
  131 + var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
  132 + foreach (var cell in headerRow.CellsUsed())
  133 + {
  134 + var key = NormalizeHeaderKey(cell.GetString());
  135 + if (string.IsNullOrEmpty(key))
  136 + {
  137 + continue;
  138 + }
  139 +
  140 + var field = MapHeaderToField(key);
  141 + if (field is null)
  142 + {
  143 + continue;
  144 + }
  145 +
  146 + if (!map.ContainsKey(field))
  147 + {
  148 + map[field] = cell.Address.ColumnNumber;
  149 + }
  150 + }
  151 +
  152 + return map;
  153 + }
  154 +
  155 + private static string? MapHeaderToField(string normalizedHeader)
  156 + {
  157 + return normalizedHeader switch
  158 + {
  159 + "name" or "fullname" or "姓名" or "成员姓名" => "fullname",
  160 + "email" or "邮箱" or "e-mail" => "email",
  161 + "username" or "login" or "userid" or "账号" or "用户名" => "username",
  162 + "password" or "pwd" or "密码" => "password",
  163 + "phone" or "mobile" or "电话" or "手机" => "phone",
  164 + "role" or "rolename" or "角色" => "rolename",
  165 + "assignedlocations" or "locations" or "location" or "分配门店" or "门店" => "locations",
  166 + "status" or "active" or "state" or "启用" => "status",
  167 + _ => null
  168 + };
  169 + }
  170 +
  171 + private static string NormalizeHeaderKey(string raw)
  172 + {
  173 + var s = raw.Trim();
  174 + if (s.Length > 0 && s[0] == '\uFEFF')
  175 + {
  176 + s = s.TrimStart('\uFEFF');
  177 + }
  178 +
  179 + s = s.Trim().TrimStart('*');
  180 + return string.Concat(s.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
  181 + }
  182 +
  183 + private static bool IsRowEmpty(Dictionary<string, int> colMap, IXLWorksheet ws, int rowNum)
  184 + {
  185 + foreach (var col in colMap.Values)
  186 + {
  187 + var t = ws.Cell(rowNum, col).GetString().Trim();
  188 + if (!string.IsNullOrEmpty(t))
  189 + {
  190 + return false;
  191 + }
  192 + }
  193 +
  194 + return true;
  195 + }
  196 +
  197 + private static string GetCellByField(Dictionary<string, int> colMap, IXLWorksheet ws, int row, string field)
  198 + {
  199 + if (!colMap.TryGetValue(field, out var col))
  200 + {
  201 + return string.Empty;
  202 + }
  203 +
  204 + return ws.Cell(row, col).GetString().Trim();
  205 + }
  206 +
  207 + private static TeamMemberCreateInputVo? BuildCreateInputFromRow(
  208 + Dictionary<string, int> colMap,
  209 + IXLWorksheet ws,
  210 + int rowNum,
  211 + IReadOnlyDictionary<string, Guid> roleNameToId,
  212 + string defaultPassword,
  213 + out List<string> errors,
  214 + out string? userNameHint)
  215 + {
  216 + errors = new List<string>();
  217 + userNameHint = null;
  218 +
  219 + var fullName = GetCellByField(colMap, ws, rowNum, "fullname");
  220 + var email = GetCellByField(colMap, ws, rowNum, "email");
  221 + var userName = GetCellByField(colMap, ws, rowNum, "username");
  222 + var password = GetCellByField(colMap, ws, rowNum, "password");
  223 + var phoneStr = GetCellByField(colMap, ws, rowNum, "phone");
  224 + var roleName = GetCellByField(colMap, ws, rowNum, "rolename");
  225 + var locationsCell = GetCellByField(colMap, ws, rowNum, "locations");
  226 + var statusStr = GetCellByField(colMap, ws, rowNum, "status");
  227 +
  228 + if (string.IsNullOrWhiteSpace(fullName))
  229 + {
  230 + errors.Add("Name 不能为空");
  231 + }
  232 +
  233 + if (string.IsNullOrWhiteSpace(email))
  234 + {
  235 + errors.Add("Email 不能为空");
  236 + }
  237 +
  238 + var login = string.IsNullOrWhiteSpace(userName) ? email.Trim() : userName.Trim();
  239 + userNameHint = login;
  240 +
  241 + if (string.IsNullOrWhiteSpace(login))
  242 + {
  243 + errors.Add("登录账号不能为空(可填 UserName 列,否则使用 Email)");
  244 + }
  245 +
  246 + var pwd = string.IsNullOrWhiteSpace(password) ? defaultPassword : password.Trim();
  247 + if (string.IsNullOrWhiteSpace(pwd))
  248 + {
  249 + errors.Add("Password 不能为空且未配置默认密码");
  250 + }
  251 +
  252 + long? phone = null;
  253 + if (!string.IsNullOrWhiteSpace(phoneStr))
  254 + {
  255 + if (!long.TryParse(RegexDigitsOnly(phoneStr), NumberStyles.Integer, CultureInfo.InvariantCulture,
  256 + out var p))
  257 + {
  258 + errors.Add("Phone 格式不正确(需为数字)");
  259 + }
  260 + else
  261 + {
  262 + phone = p;
  263 + }
  264 + }
  265 +
  266 + Guid? roleIdResolved = null;
  267 + if (string.IsNullOrWhiteSpace(roleName))
  268 + {
  269 + errors.Add("Role 不能为空");
  270 + }
  271 + else if (!roleNameToId.TryGetValue(NormalizeRoleKey(roleName), out var rid))
  272 + {
  273 + errors.Add($"未找到角色「{roleName.Trim()}」,请与系统角色名称一致");
  274 + }
  275 + else
  276 + {
  277 + roleIdResolved = rid;
  278 + }
  279 +
  280 + var locationTokens = SplitLocationTokens(locationsCell);
  281 + if (locationTokens.Count == 0)
  282 + {
  283 + errors.Add("Assigned Locations 不能为空(多个门店可用分号、竖线或换行分隔)");
  284 + }
  285 +
  286 + if (errors.Count > 0)
  287 + {
  288 + return null;
  289 + }
  290 +
  291 + var state = ParseBool(statusStr, defaultValue: true);
  292 + return new TeamMemberCreateInputVo
  293 + {
  294 + FullName = fullName.Trim(),
  295 + Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
  296 + UserName = login,
  297 + Password = pwd,
  298 + Phone = phone,
  299 + RoleId = roleIdResolved,
  300 + LocationIds = locationTokens,
  301 + State = state
  302 + };
  303 + }
  304 +
  305 + public static string NormalizeRoleKey(string roleName)
  306 + {
  307 + return string.Concat(roleName.Trim().Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
  308 + }
  309 +
  310 + /// <summary>
  311 + /// 拆分门店单元格为「待解析」片段(后续由服务层解析为 Location Id)。
  312 + /// </summary>
  313 + public static List<string> SplitLocationTokens(string locationsCell)
  314 + {
  315 + if (string.IsNullOrWhiteSpace(locationsCell))
  316 + {
  317 + return new List<string>();
  318 + }
  319 +
  320 + var parts = locationsCell
  321 + .Split(new[] { ';', '|', '\n', '\r', ',' }, StringSplitOptions.RemoveEmptyEntries)
  322 + .Select(x => x.Trim())
  323 + .Where(x => !string.IsNullOrEmpty(x))
  324 + .ToList();
  325 + return parts;
  326 + }
  327 +
  328 + private static string RegexDigitsOnly(string s)
  329 + {
  330 + return new string(s.Where(char.IsDigit).ToArray());
  331 + }
  332 +
  333 + private static bool ParseBool(string? raw, bool defaultValue)
  334 + {
  335 + if (string.IsNullOrWhiteSpace(raw))
  336 + {
  337 + return defaultValue;
  338 + }
  339 +
  340 + var s = raw.Trim();
  341 + if (bool.TryParse(s, out var b))
  342 + {
  343 + return b;
  344 + }
  345 +
  346 + if (int.TryParse(s, out var n))
  347 + {
  348 + return n != 0;
  349 + }
  350 +
  351 + if (string.Equals(s, "active", StringComparison.OrdinalIgnoreCase) ||
  352 + string.Equals(s, "是", StringComparison.Ordinal) ||
  353 + string.Equals(s, "Y", StringComparison.OrdinalIgnoreCase) ||
  354 + string.Equals(s, "Yes", StringComparison.OrdinalIgnoreCase))
  355 + {
  356 + return true;
  357 + }
  358 +
  359 + if (string.Equals(s, "inactive", StringComparison.OrdinalIgnoreCase) ||
  360 + string.Equals(s, "否", StringComparison.Ordinal) ||
  361 + string.Equals(s, "N", StringComparison.OrdinalIgnoreCase) ||
  362 + string.Equals(s, "No", StringComparison.OrdinalIgnoreCase))
  363 + {
  364 + return false;
  365 + }
  366 +
  367 + return defaultValue;
  368 + }
  369 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Options/FoodLabelingBatchImportOptions.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Options;
  2 +
  3 +/// <summary>
  4 +/// 批量导入模板目录(服务器静态路径)等配置。
  5 +/// </summary>
  6 +public class FoodLabelingBatchImportOptions
  7 +{
  8 + public const string SectionName = "FoodLabeling:BatchImport";
  9 +
  10 + /// <summary>
  11 + /// 模板文件所在目录(Linux 示例:<c>/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles</c>)
  12 + /// </summary>
  13 + public string TemplateDirectory { get; set; } =
  14 + "/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles";
  15 +
  16 + /// <summary>
  17 + /// Location Manager 导入模板文件名(与服务器上已上传文件名一致)
  18 + /// </summary>
  19 + public string LocationTemplateFileName { get; set; } = "Location-Manager-批量导入模板.xlsx";
  20 +
  21 + /// <summary>
  22 + /// Team Member 导入模板文件名(与服务器上已上传文件名一致)
  23 + /// </summary>
  24 + public string TeamMemberTemplateFileName { get; set; } = "Team-Member-批量导入模板.xlsx";
  25 +
  26 + /// <summary>
  27 + /// Product(Menu Management)导入模板文件名
  28 + /// </summary>
  29 + public string ProductTemplateFileName { get; set; } = "Product-Manager-批量导入模板.xlsx";
  30 +
  31 + /// <summary>
  32 + /// Team Member 批量导入时,Excel 未填写「Password」列则使用的默认初始密码
  33 + /// </summary>
  34 + public string TeamMemberImportDefaultPassword { get; set; } = "ChangeMe123!";
  35 +
  36 + /// <summary>
  37 + /// 单次导入最多处理的数据行数(不含表头)
  38 + /// </summary>
  39 + public int MaxImportRows { get; set; } = 5000;
  40 +
  41 + /// <summary>
  42 + /// 上传 Excel 最大体积(字节),默认 10MB
  43 + /// </summary>
  44 + public long MaxUploadBytes { get; set; } = 10 * 1024 * 1024;
  45 +
  46 + /// <summary>
  47 + /// 单次「批量编辑」请求最多允许的条数(含空行过滤前的数组长度)
  48 + /// </summary>
  49 + public int MaxBulkUpdateItems { get; set; } = 500;
  50 +}
... ...
美国版/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
196 196 .Take(ExportPdfMaxRows)
197 197 .ToListAsync();
198 198  
199   - var fileName = $"groups_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
  199 + var fileName = $"regions_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
200 200 var document = Document.Create(container =>
201 201 {
202 202 container.Page(page =>
203 203 {
204 204 page.Margin(28);
205 205 page.DefaultTextStyle(x => x.FontSize(10));
206   - page.Header().Text("Groups").SemiBold().FontSize(18);
  206 + page.Header().Text("Regions").SemiBold().FontSize(18);
207 207 page.Content().PaddingTop(12).Table(table =>
208 208 {
209 209 table.ColumnsDefinition(c =>
... ... @@ -217,8 +217,8 @@ public class GroupAppService : ApplicationService, IGroupAppService
217 217 static IContainer CellHeader(IContainer c) =>
218 218 c.Background(Colors.Grey.Lighten3).Padding(6).DefaultTextStyle(x => x.SemiBold());
219 219  
220   - table.Cell().Element(CellHeader).Text("Group Name");
221   - table.Cell().Element(CellHeader).Text("Parent Partner");
  220 + table.Cell().Element(CellHeader).Text("Region Name");
  221 + table.Cell().Element(CellHeader).Text("Parent company");
222 222 table.Cell().Element(CellHeader).Text("Status");
223 223 table.Cell().Element(CellHeader).Text("Created");
224 224  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs
  1 +using System.IO;
1 2 using FoodLabeling.Application.Helpers;
2 3 using FoodLabeling.Application.Contracts.Dtos.Location;
  4 +using FoodLabeling.Application.Contracts.Dtos.Common;
3 5 using FoodLabeling.Application.Contracts.IServices;
  6 +using FoodLabeling.Application.Options;
4 7 using FoodLabeling.Domain.Entities;
5   -using FoodLabeling.Application.Contracts.Dtos.Common;
6 8 using Microsoft.AspNetCore.Mvc;
  9 +using Microsoft.Extensions.Options;
7 10 using SqlSugar;
8 11 using Volo.Abp;
9 12 using Volo.Abp.Application.Dtos;
10 13 using Volo.Abp.Application.Services;
  14 +using Volo.Abp.Domain.Repositories;
11 15 using Yi.Framework.SqlSugarCore.Abstractions;
12 16  
13 17 namespace FoodLabeling.Application.Services;
... ... @@ -18,10 +22,14 @@ namespace FoodLabeling.Application.Services;
18 22 public class LocationAppService : ApplicationService, ILocationAppService
19 23 {
20 24 private readonly ISqlSugarRepository<LocationAggregateRoot, Guid> _locationRepository;
  25 + private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions;
21 26  
22   - public LocationAppService(ISqlSugarRepository<LocationAggregateRoot, Guid> locationRepository)
  27 + public LocationAppService(
  28 + ISqlSugarRepository<LocationAggregateRoot, Guid> locationRepository,
  29 + IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions)
23 30 {
24 31 _locationRepository = locationRepository;
  32 + _batchImportOptions = batchImportOptions;
25 33 }
26 34  
27 35 /// <inheritdoc />
... ... @@ -29,29 +37,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
29 37 {
30 38 RefAsync<int> total = 0;
31 39  
32   - var keyword = input.Keyword?.Trim();
33   - var partner = input.Partner?.Trim();
34   - var groupName = input.GroupName?.Trim();
35   -
36   - var query = _locationRepository._DbQueryable
37   - .Where(x => x.IsDeleted == false)
38   - .WhereIF(!string.IsNullOrEmpty(partner), x => x.Partner == partner)
39   - .WhereIF(!string.IsNullOrEmpty(groupName), x => x.GroupName == groupName)
40   - .WhereIF(input.State is not null, x => x.State == input.State)
41   - .WhereIF(!string.IsNullOrEmpty(keyword),
42   - x =>
43   - x.LocationCode.Contains(keyword!) ||
44   - x.LocationName.Contains(keyword!) ||
45   - (x.Street != null && x.Street.Contains(keyword!)) ||
46   - (x.City != null && x.City.Contains(keyword!)) ||
47   - (x.StateCode != null && x.StateCode.Contains(keyword!)) ||
48   - (x.Country != null && x.Country.Contains(keyword!)) ||
49   - (x.ZipCode != null && x.ZipCode.Contains(keyword!)) ||
50   - (x.Phone != null && x.Phone.Contains(keyword!)) ||
51   - (x.Email != null && x.Email.Contains(keyword!))
52   - );
53   -
54   - // 先按排序字段走(如前端传入),否则默认按创建时间倒序
  40 + var query = BuildFilteredQuery(input);
55 41 if (!string.IsNullOrWhiteSpace(input.Sorting))
56 42 {
57 43 query = query.OrderBy(input.Sorting);
... ... @@ -63,34 +49,17 @@ public class LocationAppService : ApplicationService, ILocationAppService
63 49  
64 50 var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
65 51  
66   - var items = entities.Select(x => new LocationGetListOutputDto
67   - {
68   - Id = x.Id,
69   - Partner = x.Partner,
70   - GroupName = x.GroupName,
71   - LocationCode = x.LocationCode,
72   - LocationName = x.LocationName,
73   - Street = x.Street,
74   - City = x.City,
75   - StateCode = x.StateCode,
76   - Country = x.Country,
77   - ZipCode = x.ZipCode,
78   - Phone = x.Phone,
79   - Email = x.Email,
80   - Latitude = x.Latitude,
81   - Longitude = x.Longitude,
82   - State = x.State
83   - }).ToList();
  52 + var items = entities.Select(ToListDto).ToList();
84 53  
85 54 var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount;
86 55 var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
87   - var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
  56 + var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total.Value / (double)pageSize);
88 57  
89 58 return new PagedResultWithPageDto<LocationGetListOutputDto>
90 59 {
91 60 PageIndex = pageIndex,
92 61 PageSize = pageSize,
93   - TotalCount = total,
  62 + TotalCount = total.Value,
94 63 TotalPages = totalPages,
95 64 Items = items
96 65 };
... ... @@ -137,24 +106,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
137 106  
138 107 await _locationRepository.InsertAsync(entity);
139 108  
140   - return new LocationGetListOutputDto
141   - {
142   - Id = entity.Id,
143   - Partner = entity.Partner,
144   - GroupName = entity.GroupName,
145   - LocationCode = entity.LocationCode,
146   - LocationName = entity.LocationName,
147   - Street = entity.Street,
148   - City = entity.City,
149   - StateCode = entity.StateCode,
150   - Country = entity.Country,
151   - ZipCode = entity.ZipCode,
152   - Phone = entity.Phone,
153   - Email = entity.Email,
154   - Latitude = entity.Latitude,
155   - Longitude = entity.Longitude,
156   - State = entity.State
157   - };
  109 + return ToListDto(entity);
158 110 }
159 111  
160 112 /// <inheritdoc />
... ... @@ -172,7 +124,6 @@ public class LocationAppService : ApplicationService, ILocationAppService
172 124 throw new UserFriendlyException("Location Name不能为空");
173 125 }
174 126  
175   - // LocationCode 默认不允许修改:业务编码需要保持唯一且稳定(如需变更,应走“新建+迁移”方案)
176 127 entity.Partner = input.Partner?.Trim();
177 128 entity.GroupName = input.GroupName?.Trim();
178 129 entity.LocationName = locationName;
... ... @@ -189,24 +140,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
189 140  
190 141 await _locationRepository.UpdateAsync(entity);
191 142  
192   - return new LocationGetListOutputDto
193   - {
194   - Id = entity.Id,
195   - Partner = entity.Partner,
196   - GroupName = entity.GroupName,
197   - LocationCode = entity.LocationCode,
198   - LocationName = entity.LocationName,
199   - Street = entity.Street,
200   - City = entity.City,
201   - StateCode = entity.StateCode,
202   - Country = entity.Country,
203   - ZipCode = entity.ZipCode,
204   - Phone = entity.Phone,
205   - Email = entity.Email,
206   - Latitude = entity.Latitude,
207   - Longitude = entity.Longitude,
208   - State = entity.State
209   - };
  143 + return ToListDto(entity);
210 144 }
211 145  
212 146 /// <inheritdoc />
... ... @@ -221,5 +155,219 @@ public class LocationAppService : ApplicationService, ILocationAppService
221 155 entity.IsDeleted = true;
222 156 await _locationRepository.UpdateAsync(entity);
223 157 }
224   -}
225 158  
  159 + /// <inheritdoc />
  160 + public Task<IActionResult> DownloadLocationImportTemplateAsync()
  161 + {
  162 + var opt = _batchImportOptions.Value;
  163 + var dir = opt.TemplateDirectory?.Trim();
  164 + if (string.IsNullOrWhiteSpace(dir))
  165 + {
  166 + throw new UserFriendlyException("未配置批量导入模板目录 FoodLabeling:BatchImport:TemplateDirectory");
  167 + }
  168 +
  169 + var fileName = opt.LocationTemplateFileName?.Trim();
  170 + if (string.IsNullOrWhiteSpace(fileName))
  171 + {
  172 + throw new UserFriendlyException("未配置模板文件名 FoodLabeling:BatchImport:LocationTemplateFileName");
  173 + }
  174 +
  175 + var fullPath = Path.Combine(dir, fileName);
  176 + if (!File.Exists(fullPath))
  177 + {
  178 + throw new UserFriendlyException($"模板文件不存在:{fullPath}");
  179 + }
  180 +
  181 + var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
  182 + const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
  183 + return Task.FromResult<IActionResult>(new FileStreamResult(stream, contentType)
  184 + {
  185 + FileDownloadName = fileName
  186 + });
  187 + }
  188 +
  189 + /// <inheritdoc />
  190 + public async Task<IActionResult> ExportLocationsExcelAsync([FromQuery] LocationGetListInputVo input)
  191 + {
  192 + var exportFilter = new LocationGetListInputVo
  193 + {
  194 + Sorting = input.Sorting,
  195 + Keyword = input.Keyword,
  196 + Partner = input.Partner,
  197 + GroupName = input.GroupName,
  198 + State = input.State
  199 + };
  200 +
  201 + var query = BuildFilteredQuery(exportFilter);
  202 + if (!string.IsNullOrWhiteSpace(exportFilter.Sorting))
  203 + {
  204 + query = query.OrderBy(exportFilter.Sorting);
  205 + }
  206 + else
  207 + {
  208 + query = query.OrderBy(x => x.CreationTime, OrderByType.Desc);
  209 + }
  210 +
  211 + var entities = await query.ToListAsync();
  212 + var rows = entities.Select(ToListDto).ToList();
  213 +
  214 + var ms = LocationBatchExcelHelper.BuildExportWorkbook(rows);
  215 + const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
  216 + var downloadName = $"locations-export-{Clock.Now:yyyyMMdd-HHmmss}.xlsx";
  217 + return new FileStreamResult(ms, contentType) { FileDownloadName = downloadName };
  218 + }
  219 +
  220 + /// <inheritdoc />
  221 + public async Task<LocationBatchImportResultDto> ImportLocationsBatchAsync([FromForm] LocationBatchImportInputVo input)
  222 + {
  223 + if (input?.File is null || input.File.Length == 0)
  224 + {
  225 + throw new UserFriendlyException("请上传 Excel 文件(form 字段名:file)");
  226 + }
  227 +
  228 + var opt = _batchImportOptions.Value;
  229 + if (input.File.Length > opt.MaxUploadBytes)
  230 + {
  231 + throw new UserFriendlyException($"文件过大,最大允许 {opt.MaxUploadBytes / 1024 / 1024} MB");
  232 + }
  233 +
  234 + var ext = Path.GetExtension(input.File.FileName)?.ToLowerInvariant();
  235 + if (ext != ".xlsx")
  236 + {
  237 + throw new UserFriendlyException("仅支持 .xlsx 格式的 Excel 文件");
  238 + }
  239 +
  240 + await using var uploadStream = input.File.OpenReadStream();
  241 + var parseErrors = new List<LocationBatchImportErrorDto>();
  242 + var rows = LocationBatchExcelHelper.ParseImportWorkbook(
  243 + uploadStream,
  244 + opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows,
  245 + out var headerErrors);
  246 + parseErrors.AddRange(headerErrors);
  247 +
  248 + var result = new LocationBatchImportResultDto();
  249 + if (rows.Count == 0 && parseErrors.Count > 0)
  250 + {
  251 + result.Errors = parseErrors;
  252 + result.FailCount = parseErrors.Count;
  253 + return result;
  254 + }
  255 +
  256 + foreach (var (rowNum, vo) in rows)
  257 + {
  258 + try
  259 + {
  260 + await CreateAsync(vo);
  261 + result.SuccessCount++;
  262 + }
  263 + catch (UserFriendlyException ex)
  264 + {
  265 + result.FailCount++;
  266 + result.Errors.Add(new LocationBatchImportErrorDto
  267 + {
  268 + RowNumber = rowNum,
  269 + LocationCode = vo.LocationCode,
  270 + Message = ex.Message
  271 + });
  272 + }
  273 + }
  274 +
  275 + result.Errors.InsertRange(0, parseErrors);
  276 + return result;
  277 + }
  278 +
  279 + /// <inheritdoc />
  280 + public async Task<LocationBulkUpdateResultDto> UpdateLocationsBulkAsync([FromBody] LocationBulkUpdateInputVo input)
  281 + {
  282 + if (input?.Items is null || input.Items.Count == 0)
  283 + {
  284 + throw new UserFriendlyException("请至少提交一条编辑数据(items 不能为空)");
  285 + }
  286 +
  287 + var opt = _batchImportOptions.Value;
  288 + var maxItems = opt.MaxBulkUpdateItems <= 0 ? 500 : opt.MaxBulkUpdateItems;
  289 + if (input.Items.Count > maxItems)
  290 + {
  291 + throw new UserFriendlyException($"单次批量编辑最多允许 {maxItems} 条,请分批提交");
  292 + }
  293 +
  294 + var effectiveCount = input.Items.Count(static x => x is not null && x.Id != Guid.Empty);
  295 + if (effectiveCount == 0)
  296 + {
  297 + throw new UserFriendlyException("没有有效的门店 Id(请为待保存行填写 id,空行请使用 id 为空 GUID 或从列表中移除)");
  298 + }
  299 +
  300 + var result = new LocationBulkUpdateResultDto();
  301 + for (var i = 0; i < input.Items.Count; i++)
  302 + {
  303 + var item = input.Items[i];
  304 + if (item is null || item.Id == Guid.Empty)
  305 + {
  306 + continue;
  307 + }
  308 +
  309 + try
  310 + {
  311 + await UpdateAsync(item.Id, item);
  312 + result.SuccessCount++;
  313 + }
  314 + catch (UserFriendlyException ex)
  315 + {
  316 + result.FailCount++;
  317 + result.Errors.Add(new LocationBulkUpdateErrorDto
  318 + {
  319 + RowNumber = i + 1,
  320 + Id = item.Id,
  321 + Message = ex.Message
  322 + });
  323 + }
  324 + }
  325 +
  326 + return result;
  327 + }
  328 +
  329 + private ISugarQueryable<LocationAggregateRoot> BuildFilteredQuery(LocationGetListInputVo input)
  330 + {
  331 + var keyword = input.Keyword?.Trim();
  332 + var partner = input.Partner?.Trim();
  333 + var groupName = input.GroupName?.Trim();
  334 +
  335 + return _locationRepository._DbQueryable
  336 + .Where(x => x.IsDeleted == false)
  337 + .WhereIF(!string.IsNullOrEmpty(partner), x => x.Partner == partner)
  338 + .WhereIF(!string.IsNullOrEmpty(groupName), x => x.GroupName == groupName)
  339 + .WhereIF(input.State is not null, x => x.State == input.State)
  340 + .WhereIF(!string.IsNullOrEmpty(keyword),
  341 + x =>
  342 + x.LocationCode.Contains(keyword!) ||
  343 + x.LocationName.Contains(keyword!) ||
  344 + (x.Street != null && x.Street.Contains(keyword!)) ||
  345 + (x.City != null && x.City.Contains(keyword!)) ||
  346 + (x.StateCode != null && x.StateCode.Contains(keyword!)) ||
  347 + (x.Country != null && x.Country.Contains(keyword!)) ||
  348 + (x.ZipCode != null && x.ZipCode.Contains(keyword!)) ||
  349 + (x.Phone != null && x.Phone.Contains(keyword!)) ||
  350 + (x.Email != null && x.Email.Contains(keyword!))
  351 + );
  352 + }
  353 +
  354 + private static LocationGetListOutputDto ToListDto(LocationAggregateRoot x) =>
  355 + new()
  356 + {
  357 + Id = x.Id,
  358 + Partner = x.Partner,
  359 + GroupName = x.GroupName,
  360 + LocationCode = x.LocationCode,
  361 + LocationName = x.LocationName,
  362 + Street = x.Street,
  363 + City = x.City,
  364 + StateCode = x.StateCode,
  365 + Country = x.Country,
  366 + ZipCode = x.ZipCode,
  367 + Phone = x.Phone,
  368 + Email = x.Email,
  369 + Latitude = x.Latitude,
  370 + Longitude = x.Longitude,
  371 + State = x.State
  372 + };
  373 +}
... ...
美国版/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
173 173 }
174 174  
175 175 var rows = await query.Take(ExportPdfMaxRows).ToListAsync();
176   - var fileName = $"partners_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
  176 + var fileName = $"companies_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
177 177  
178 178 var document = Document.Create(container =>
179 179 {
... ... @@ -181,7 +181,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
181 181 {
182 182 page.Margin(28);
183 183 page.DefaultTextStyle(x => x.FontSize(10));
184   - page.Header().Text("Partners").SemiBold().FontSize(18);
  184 + page.Header().Text("Companies").SemiBold().FontSize(18);
185 185 page.Content().PaddingTop(12).Table(table =>
186 186 {
187 187 table.ColumnsDefinition(c =>
... ... @@ -196,7 +196,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService
196 196 static IContainer CellHeader(IContainer c) =>
197 197 c.Background(Colors.Grey.Lighten3).Padding(6).DefaultTextStyle(x => x.SemiBold());
198 198  
199   - table.Cell().Element(CellHeader).Text("Partner");
  199 + table.Cell().Element(CellHeader).Text("Company");
200 200 table.Cell().Element(CellHeader).Text("Contact");
201 201 table.Cell().Element(CellHeader).Text("Phone");
202 202 table.Cell().Element(CellHeader).Text("Status");
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
1   -using FoodLabeling.Application.Helpers;
  1 +using System.IO;
2 2 using FoodLabeling.Application.Contracts.Dtos.Common;
3 3 using FoodLabeling.Application.Contracts.Dtos.Product;
4 4 using FoodLabeling.Application.Contracts.IServices;
  5 +using FoodLabeling.Application.Helpers;
  6 +using FoodLabeling.Application.Options;
5 7 using FoodLabeling.Application.Services.DbModels;
6 8 using FoodLabeling.Domain.Entities;
  9 +using Microsoft.AspNetCore.Mvc;
  10 +using Microsoft.Extensions.Options;
7 11 using SqlSugar;
8 12 using Volo.Abp;
9 13 using Volo.Abp.Application.Services;
... ... @@ -20,34 +24,23 @@ public class ProductAppService : ApplicationService, IProductAppService
20 24 {
21 25 private readonly ISqlSugarDbContext _dbContext;
22 26 private readonly IGuidGenerator _guidGenerator;
  27 + private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions;
23 28  
24   - public ProductAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator)
  29 + public ProductAppService(
  30 + ISqlSugarDbContext dbContext,
  31 + IGuidGenerator guidGenerator,
  32 + IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions)
25 33 {
26 34 _dbContext = dbContext;
27 35 _guidGenerator = guidGenerator;
  36 + _batchImportOptions = batchImportOptions;
28 37 }
29 38  
30 39 public async Task<PagedResultWithPageDto<ProductGetListOutputDto>> GetListAsync(ProductGetListInputVo input)
31 40 {
32 41 RefAsync<int> total = 0;
33   - var keyword = input.Keyword?.Trim();
34   -
35   - var query = _dbContext.SqlSugarClient
36   - .Queryable<FlProductDbEntity>()
37   - .Where(x => !x.IsDeleted)
38   - .WhereIF(input.State != null, x => x.State == input.State);
39   -
40   - if (!string.IsNullOrWhiteSpace(keyword))
41   - {
42   - query = query
43   - .LeftJoin<FlProductCategoryDbEntity>((p, c) => p.CategoryId == c.Id)
44   - .Where((p, c) =>
45   - p.ProductCode.Contains(keyword!) ||
46   - p.ProductName.Contains(keyword!) ||
47   - (c.CategoryName != null && c.CategoryName.Contains(keyword!)))
48   - .Select((p, c) => p);
49   - }
50 42  
  43 + var query = BuildFilteredProductQuery(input);
51 44 if (!string.IsNullOrWhiteSpace(input.Sorting))
52 45 {
53 46 query = query.OrderBy(input.Sorting);
... ... @@ -111,7 +104,7 @@ public class ProductAppService : ApplicationService, IProductAppService
111 104 };
112 105 }).ToList();
113 106  
114   - return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
  107 + return BuildPagedResult(input.SkipCount, input.MaxResultCount, (int)total, items);
115 108 }
116 109  
117 110 public async Task<ProductGetOutputDto> GetAsync(string id)
... ... @@ -163,18 +156,25 @@ public class ProductAppService : ApplicationService, IProductAppService
163 156 [UnitOfWork]
164 157 public async Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input)
165 158 {
166   - var code = input.ProductCode?.Trim();
167 159 var name = input.ProductName?.Trim();
168   - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name))
  160 + if (string.IsNullOrWhiteSpace(name))
169 161 {
170   - throw new UserFriendlyException("产品编码和名称不能为空");
  162 + throw new UserFriendlyException("产品名称不能为空");
171 163 }
172 164  
173   - var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
174   - .AnyAsync(x => !x.IsDeleted && (x.ProductCode == code));
175   - if (duplicated)
  165 + var code = input.ProductCode?.Trim();
  166 + if (string.IsNullOrWhiteSpace(code))
176 167 {
177   - throw new UserFriendlyException("产品编码已存在");
  168 + code = await GenerateUniqueProductCodeAsync();
  169 + }
  170 + else
  171 + {
  172 + var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
  173 + .AnyAsync(x => !x.IsDeleted && x.ProductCode == code);
  174 + if (duplicated)
  175 + {
  176 + throw new UserFriendlyException("产品编码已存在");
  177 + }
178 178 }
179 179  
180 180 var entity = new FlProductDbEntity
... ... @@ -215,18 +215,27 @@ public class ProductAppService : ApplicationService, IProductAppService
215 215 throw new UserFriendlyException("产品不存在");
216 216 }
217 217  
218   - var code = input.ProductCode?.Trim();
219 218 var name = input.ProductName?.Trim();
220   - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name))
  219 + if (string.IsNullOrWhiteSpace(name))
221 220 {
222   - throw new UserFriendlyException("产品编码和名称不能为空");
  221 + throw new UserFriendlyException("产品名称不能为空");
223 222 }
224 223  
225   - var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
226   - .AnyAsync(x => !x.IsDeleted && x.Id != productId && x.ProductCode == code);
227   - if (duplicated)
  224 + var codeInput = input.ProductCode?.Trim();
  225 + var code = string.IsNullOrWhiteSpace(codeInput) ? entity.ProductCode : codeInput;
  226 + if (string.IsNullOrWhiteSpace(code))
228 227 {
229   - throw new UserFriendlyException("产品编码已存在");
  228 + code = await GenerateUniqueProductCodeAsync();
  229 + }
  230 +
  231 + if (code != entity.ProductCode)
  232 + {
  233 + var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
  234 + .AnyAsync(x => !x.IsDeleted && x.Id != productId && x.ProductCode == code);
  235 + if (duplicated)
  236 + {
  237 + throw new UserFriendlyException("产品编码已存在");
  238 + }
230 239 }
231 240  
232 241 entity.ProductCode = code;
... ... @@ -266,6 +275,388 @@ public class ProductAppService : ApplicationService, IProductAppService
266 275 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
267 276 }
268 277  
  278 + /// <inheritdoc />
  279 + public Task<IActionResult> DownloadProductImportTemplateAsync()
  280 + {
  281 + var opt = _batchImportOptions.Value;
  282 + var dir = opt.TemplateDirectory?.Trim();
  283 + if (string.IsNullOrWhiteSpace(dir))
  284 + {
  285 + throw new UserFriendlyException("未配置批量导入模板目录 FoodLabeling:BatchImport:TemplateDirectory");
  286 + }
  287 +
  288 + var fileName = opt.ProductTemplateFileName?.Trim();
  289 + if (string.IsNullOrWhiteSpace(fileName))
  290 + {
  291 + throw new UserFriendlyException("未配置模板文件名 FoodLabeling:BatchImport:ProductTemplateFileName");
  292 + }
  293 +
  294 + var fullPath = Path.Combine(dir, fileName);
  295 + if (!File.Exists(fullPath))
  296 + {
  297 + throw new UserFriendlyException($"模板文件不存在:{fullPath}");
  298 + }
  299 +
  300 + var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
  301 + const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
  302 + return Task.FromResult<IActionResult>(new FileStreamResult(stream, contentType)
  303 + {
  304 + FileDownloadName = fileName
  305 + });
  306 + }
  307 +
  308 + /// <inheritdoc />
  309 + public async Task<IActionResult> ExportProductsExcelAsync([FromQuery] ProductGetListInputVo input)
  310 + {
  311 + var exportFilter = new ProductGetListInputVo
  312 + {
  313 + Sorting = input.Sorting,
  314 + Keyword = input.Keyword,
  315 + State = input.State
  316 + };
  317 +
  318 + var query = BuildFilteredProductQuery(exportFilter);
  319 + if (!string.IsNullOrWhiteSpace(exportFilter.Sorting))
  320 + {
  321 + query = query.OrderBy(exportFilter.Sorting);
  322 + }
  323 + else
  324 + {
  325 + query = query.OrderByDescending(x => x.ProductName);
  326 + }
  327 +
  328 + var entities = await query.ToListAsync();
  329 + var exportRows = await BuildProductExcelExportRowsAsync(entities);
  330 + var ms = ProductBatchExcelHelper.BuildExportWorkbook(exportRows);
  331 + const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
  332 + var downloadName = $"products-export-{Clock.Now:yyyyMMdd-HHmmss}.xlsx";
  333 + return new FileStreamResult(ms, contentType) { FileDownloadName = downloadName };
  334 + }
  335 +
  336 + /// <inheritdoc />
  337 + public async Task<ProductBatchImportResultDto> ImportProductsBatchAsync(
  338 + [FromForm] ProductBatchImportInputVo input)
  339 + {
  340 + if (input?.File is null || input.File.Length == 0)
  341 + {
  342 + throw new UserFriendlyException("请上传 Excel 文件(form 字段名:file)");
  343 + }
  344 +
  345 + var opt = _batchImportOptions.Value;
  346 + if (input.File.Length > opt.MaxUploadBytes)
  347 + {
  348 + throw new UserFriendlyException($"文件过大,最大允许 {opt.MaxUploadBytes / 1024 / 1024} MB");
  349 + }
  350 +
  351 + var ext = Path.GetExtension(input.File.FileName)?.ToLowerInvariant();
  352 + if (ext != ".xlsx")
  353 + {
  354 + throw new UserFriendlyException("仅支持 .xlsx 格式的 Excel 文件");
  355 + }
  356 +
  357 + await using var uploadStream = input.File.OpenReadStream();
  358 + var parseErrors = new List<ProductBatchImportErrorDto>();
  359 + var rows = ProductBatchExcelHelper.ParseImportWorkbook(
  360 + uploadStream,
  361 + opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows,
  362 + out var headerErrors);
  363 + parseErrors.AddRange(headerErrors);
  364 +
  365 + var result = new ProductBatchImportResultDto();
  366 + if (rows.Count == 0 && parseErrors.Count > 0)
  367 + {
  368 + result.Errors = parseErrors;
  369 + result.FailCount = parseErrors.Count;
  370 + return result;
  371 + }
  372 +
  373 + foreach (var (rowNum, locCell, catName, prodName, codeStr) in rows)
  374 + {
  375 + try
  376 + {
  377 + var categoryId = await ResolveCategoryIdByNameAsync(catName);
  378 + List<string>? locationIds = null;
  379 + if (!string.IsNullOrWhiteSpace(locCell))
  380 + {
  381 + locationIds = await ResolveLocationIdsFromImportDisplayCellAsync(locCell);
  382 + }
  383 +
  384 + await CreateAsync(new ProductCreateInputVo
  385 + {
  386 + ProductName = prodName,
  387 + CategoryId = categoryId,
  388 + ProductCode = string.IsNullOrWhiteSpace(codeStr) ? null : codeStr,
  389 + State = true,
  390 + LocationIds = locationIds
  391 + });
  392 + result.SuccessCount++;
  393 + }
  394 + catch (UserFriendlyException ex)
  395 + {
  396 + result.FailCount++;
  397 + result.Errors.Add(new ProductBatchImportErrorDto
  398 + {
  399 + RowNumber = rowNum,
  400 + ProductName = prodName,
  401 + Message = ex.Message
  402 + });
  403 + }
  404 + }
  405 +
  406 + result.Errors.InsertRange(0, parseErrors);
  407 + return result;
  408 + }
  409 +
  410 + /// <inheritdoc />
  411 + public async Task<ProductBulkUpdateResultDto> UpdateProductsBulkAsync(
  412 + [FromBody] ProductBulkUpdateInputVo input)
  413 + {
  414 + if (input?.Items is null || input.Items.Count == 0)
  415 + {
  416 + throw new UserFriendlyException("请至少提交一条编辑数据(items 不能为空)");
  417 + }
  418 +
  419 + var opt = _batchImportOptions.Value;
  420 + var maxItems = opt.MaxBulkUpdateItems <= 0 ? 500 : opt.MaxBulkUpdateItems;
  421 + if (input.Items.Count > maxItems)
  422 + {
  423 + throw new UserFriendlyException($"单次批量编辑最多允许 {maxItems} 条,请分批提交");
  424 + }
  425 +
  426 + var effectiveCount = input.Items.Count(static x => x is not null && !string.IsNullOrWhiteSpace(x.Id));
  427 + if (effectiveCount == 0)
  428 + {
  429 + throw new UserFriendlyException("没有有效的产品 Id(请为待保存行填写 id)");
  430 + }
  431 +
  432 + var result = new ProductBulkUpdateResultDto();
  433 + for (var i = 0; i < input.Items.Count; i++)
  434 + {
  435 + var item = input.Items[i];
  436 + if (item is null || string.IsNullOrWhiteSpace(item.Id))
  437 + {
  438 + continue;
  439 + }
  440 +
  441 + try
  442 + {
  443 + await UpdateAsync(item.Id.Trim(), item);
  444 + result.SuccessCount++;
  445 + }
  446 + catch (UserFriendlyException ex)
  447 + {
  448 + result.FailCount++;
  449 + result.Errors.Add(new ProductBulkUpdateErrorDto
  450 + {
  451 + RowNumber = i + 1,
  452 + Id = item.Id.Trim(),
  453 + Message = ex.Message
  454 + });
  455 + }
  456 + }
  457 +
  458 + return result;
  459 + }
  460 +
  461 + private ISugarQueryable<FlProductDbEntity> BuildFilteredProductQuery(ProductGetListInputVo input)
  462 + {
  463 + var keyword = input.Keyword?.Trim();
  464 +
  465 + var query = _dbContext.SqlSugarClient
  466 + .Queryable<FlProductDbEntity>()
  467 + .Where(x => !x.IsDeleted)
  468 + .WhereIF(input.State != null, x => x.State == input.State);
  469 +
  470 + if (!string.IsNullOrWhiteSpace(keyword))
  471 + {
  472 + query = query
  473 + .LeftJoin<FlProductCategoryDbEntity>((p, c) => p.CategoryId == c.Id)
  474 + .Where((p, c) =>
  475 + p.ProductCode.Contains(keyword!) ||
  476 + p.ProductName.Contains(keyword!) ||
  477 + (c.CategoryName != null && c.CategoryName.Contains(keyword!)))
  478 + .Select((p, c) => p);
  479 + }
  480 +
  481 + return query;
  482 + }
  483 +
  484 + private async Task<List<ProductBatchExcelHelper.ExportRow>> BuildProductExcelExportRowsAsync(
  485 + List<FlProductDbEntity> entities)
  486 + {
  487 + if (entities.Count == 0)
  488 + {
  489 + return new List<ProductBatchExcelHelper.ExportRow>();
  490 + }
  491 +
  492 + var categoryIds = entities
  493 + .Select(x => x.CategoryId)
  494 + .Where(x => !string.IsNullOrWhiteSpace(x))
  495 + .Select(x => x!.Trim())
  496 + .Distinct()
  497 + .ToList();
  498 +
  499 + var categoryMap = new Dictionary<string, FlProductCategoryDbEntity>();
  500 + if (categoryIds.Count > 0)
  501 + {
  502 + var cats = await _dbContext.SqlSugarClient.Queryable<FlProductCategoryDbEntity>()
  503 + .Where(x => !x.IsDeleted && categoryIds.Contains(x.Id))
  504 + .ToListAsync();
  505 + categoryMap = cats.ToDictionary(x => x.Id, x => x);
  506 + }
  507 +
  508 + var productIds = entities.Select(x => x.Id).ToList();
  509 + var links = await _dbContext.SqlSugarClient.Queryable<FlLocationProductDbEntity>()
  510 + .Where(x => productIds.Contains(x.ProductId))
  511 + .ToListAsync();
  512 +
  513 + var locIdSet = links
  514 + .Select(x => x.LocationId)
  515 + .Where(x => !string.IsNullOrWhiteSpace(x))
  516 + .Select(x => x.Trim())
  517 + .Distinct()
  518 + .ToList();
  519 +
  520 + var locs = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  521 + .Where(x => !x.IsDeleted && locIdSet.Contains(x.Id.ToString()))
  522 + .Select(x => new { x.Id, x.LocationName })
  523 + .ToListAsync();
  524 +
  525 + var locNameById = locs.ToDictionary(x => x.Id.ToString(), x => x.LocationName?.Trim() ?? string.Empty);
  526 +
  527 + var locDisplayByProduct = links
  528 + .GroupBy(x => x.ProductId)
  529 + .ToDictionary(
  530 + g => g.Key,
  531 + g => string.Join(", ",
  532 + g.Select(y => y.LocationId.Trim())
  533 + .Distinct()
  534 + .Select(lid => locNameById.GetValueOrDefault(lid, lid))
  535 + .Where(s => !string.IsNullOrEmpty(s))
  536 + .Distinct()));
  537 +
  538 + var rows = new List<ProductBatchExcelHelper.ExportRow>();
  539 + foreach (var e in entities)
  540 + {
  541 + var catName = "无";
  542 + if (!string.IsNullOrWhiteSpace(e.CategoryId) && categoryMap.TryGetValue(e.CategoryId.Trim(), out var c))
  543 + {
  544 + catName = string.IsNullOrWhiteSpace(c.CategoryName) ? "无" : c.CategoryName.Trim();
  545 + }
  546 +
  547 + locDisplayByProduct.TryGetValue(e.Id, out var locDisp);
  548 + var locationDisplay = string.IsNullOrWhiteSpace(locDisp) ? string.Empty : locDisp;
  549 + rows.Add(new ProductBatchExcelHelper.ExportRow(
  550 + locationDisplay,
  551 + catName,
  552 + e.ProductName ?? string.Empty,
  553 + e.ProductCode ?? string.Empty));
  554 + }
  555 +
  556 + return rows;
  557 + }
  558 +
  559 + private async Task<string> ResolveCategoryIdByNameAsync(string categoryName)
  560 + {
  561 + var n = categoryName.Trim();
  562 + if (string.IsNullOrWhiteSpace(n))
  563 + {
  564 + throw new UserFriendlyException("产品分类名称不能为空");
  565 + }
  566 +
  567 + var lowered = n.ToLowerInvariant();
  568 + var matches = await _dbContext.SqlSugarClient.Queryable<FlProductCategoryDbEntity>()
  569 + .Where(x => !x.IsDeleted && x.CategoryName.ToLower() == lowered)
  570 + .ToListAsync();
  571 +
  572 + if (matches.Count == 0)
  573 + {
  574 + throw new UserFriendlyException($"未找到产品分类「{n}」");
  575 + }
  576 +
  577 + if (matches.Count > 1)
  578 + {
  579 + throw new UserFriendlyException($"产品分类「{n}」存在多条记录,请在系统中使用唯一名称");
  580 + }
  581 +
  582 + return matches[0].Id;
  583 + }
  584 +
  585 + private async Task<List<string>> ResolveLocationIdsFromImportDisplayCellAsync(string cell)
  586 + {
  587 + var tokens = cell.Split(new[] { ',', ',', ';', ';', '|', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
  588 + .Select(x => x.Trim())
  589 + .Where(x => !string.IsNullOrEmpty(x))
  590 + .Distinct(StringComparer.Ordinal)
  591 + .ToList();
  592 +
  593 + var result = new List<string>();
  594 + foreach (var t in tokens)
  595 + {
  596 + var idStr = await ResolveSingleLocationTokenToIdStringAsync(t);
  597 + result.Add(idStr);
  598 + }
  599 +
  600 + return result.Distinct(StringComparer.Ordinal).ToList();
  601 + }
  602 +
  603 + private async Task<string> ResolveSingleLocationTokenToIdStringAsync(string token)
  604 + {
  605 + var t = token.Trim();
  606 + if (Guid.TryParse(t, out var gid))
  607 + {
  608 + var byIdList = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  609 + .Where(x => !x.IsDeleted && x.Id == gid)
  610 + .Take(1)
  611 + .ToListAsync();
  612 + var byId = byIdList.FirstOrDefault();
  613 + if (byId is null)
  614 + {
  615 + throw new UserFriendlyException($"未找到门店 Id:{t}");
  616 + }
  617 +
  618 + return byId.Id.ToString();
  619 + }
  620 +
  621 + var lowered = t.ToLowerInvariant();
  622 + var matches = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  623 + .Where(x => !x.IsDeleted &&
  624 + (x.LocationCode == t ||
  625 + x.LocationName.ToLower() == lowered))
  626 + .ToListAsync();
  627 +
  628 + if (matches.Count == 0)
  629 + {
  630 + throw new UserFriendlyException($"未找到门店:{t}");
  631 + }
  632 +
  633 + if (matches.Count > 1)
  634 + {
  635 + throw new UserFriendlyException($"门店「{t}」存在多条匹配,请使用 Location Code 或 Guid");
  636 + }
  637 +
  638 + return matches[0].Id.ToString();
  639 + }
  640 +
  641 + /// <summary>
  642 + /// 生成未删除数据中不重复的 <c>PRD_</c> 前缀产品编码。
  643 + /// </summary>
  644 + private async Task<string> GenerateUniqueProductCodeAsync()
  645 + {
  646 + for (var i = 0; i < 8; i++)
  647 + {
  648 + var code = $"PRD_{_guidGenerator.Create():N}";
  649 + var exists = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
  650 + .AnyAsync(x => !x.IsDeleted && x.ProductCode == code);
  651 + if (!exists)
  652 + {
  653 + return code;
  654 + }
  655 + }
  656 +
  657 + throw new UserFriendlyException("无法生成唯一产品编码,请稍后重试或手动填写产品编码");
  658 + }
  659 +
269 660 /// <summary>
270 661 /// 去重、校验门店 Id 格式与存在性。
271 662 /// </summary>
... ... @@ -341,4 +732,3 @@ public class ProductAppService : ApplicationService, IProductAppService
341 732 };
342 733 }
343 734 }
344   -
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ReportsAppService.cs
1 1 using System.Globalization;
2 2 using System.Text.Json;
3   -using FoodLabeling.Application.Helpers;
4 3 using FoodLabeling.Application.Contracts.Dtos.Common;
  4 +using FoodLabeling.Application.Helpers;
5 5 using FoodLabeling.Application.Contracts.Dtos.Reports;
6 6 using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
7 7 using FoodLabeling.Application.Contracts.IServices;
... ... @@ -258,6 +258,89 @@ public class ReportsAppService : ApplicationService, IReportsAppService
258 258 }
259 259  
260 260 /// <inheritdoc />
  261 + public async Task<IActionResult> ExportPrintLogExcelAsync([FromQuery] ReportsPrintLogGetListInputVo input)
  262 + {
  263 + if (input is null)
  264 + {
  265 + throw new UserFriendlyException("入参不能为空");
  266 + }
  267 +
  268 + if (!CurrentUser.Id.HasValue)
  269 + {
  270 + throw new UserFriendlyException("用户未登录");
  271 + }
  272 +
  273 + var locationIds = await ResolveFilteredLocationIdsAsync(input.PartnerId, input.GroupId, input.LocationId);
  274 + if (locationIds is not null && locationIds.Count == 0)
  275 + {
  276 + var emptyMs = ReportsPrintLogExcelHelper.BuildWorkbook(Array.Empty<ReportsPrintLogListItemDto>());
  277 + var emptyName = $"print-log_{Clock.Now:yyyyMMdd-HHmmss}.xlsx";
  278 + return new FileStreamResult(emptyMs, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
  279 + { FileDownloadName = emptyName };
  280 + }
  281 +
  282 + var (rangeStart, rangeEndExcl) = ResolveDateRange(input.StartDate, input.EndDate);
  283 + var isAdmin = ReportsRoleHelper.IsAdminRole(CurrentUser);
  284 + var currentUserIdStr = CurrentUser.Id.Value.ToString();
  285 + var keyword = input.Keyword?.Trim();
  286 +
  287 + var query = BuildReportTaskCore(locationIds, isAdmin, currentUserIdStr, keyword)
  288 + .LeftJoin<FlLabelTemplateDbEntity>((t, l, p, lc, pc, loc, tpl) => t.TemplateId == tpl.Id)
  289 + .Where((t, l, p, lc, pc, loc, tpl) =>
  290 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= rangeStart &&
  291 + SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < rangeEndExcl);
  292 +
  293 + if (!string.IsNullOrWhiteSpace(input.Sorting) &&
  294 + input.Sorting.Trim().Equals("PrintedAt asc", StringComparison.OrdinalIgnoreCase))
  295 + {
  296 + query = query.OrderBy((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
  297 + OrderByType.Asc);
  298 + }
  299 + else
  300 + {
  301 + query = query.OrderBy((t, l, p, lc, pc, loc, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
  302 + OrderByType.Desc);
  303 + }
  304 +
  305 + var count = await query.CountAsync();
  306 + if (count > ExportPdfMaxRows)
  307 + {
  308 + throw new UserFriendlyException($"导出数据超过上限 {ExportPdfMaxRows} 条,请缩小筛选范围");
  309 + }
  310 +
  311 + var pageRows = await query.Take(ExportPdfMaxRows)
  312 + .Select((t, l, p, lc, pc, loc, tpl) => new PrintLogExportRow
  313 + {
  314 + Id = t.Id,
  315 + LabelCode = l.LabelCode,
  316 + ProductName = p.ProductName,
  317 + LabelCategoryName = lc.CategoryName,
  318 + ProductCategoryName = pc.CategoryName,
  319 + Width = tpl.Width,
  320 + Height = tpl.Height,
  321 + Unit = tpl.Unit,
  322 + TemplateName = tpl.TemplateName,
  323 + PrintInputJson = t.PrintInputJson,
  324 + PrintedAt = SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
  325 + CreatedBy = t.CreatedBy,
  326 + LocationId = t.LocationId,
  327 + LocName = loc.LocationName,
  328 + LocCode = loc.LocationCode
  329 + })
  330 + .ToListAsync();
  331 +
  332 + var userMap = await LoadUserNameMapAsync(pageRows.Select(x => x.CreatedBy).Where(x => !string.IsNullOrWhiteSpace(x))
  333 + .Select(x => x!).Distinct().ToList());
  334 +
  335 + var items = pageRows.Select(x => MapPrintLogExportRowToListItem(x, userMap)).ToList();
  336 +
  337 + var ms = ReportsPrintLogExcelHelper.BuildWorkbook(items);
  338 + var fileName = $"print-log_{Clock.Now:yyyyMMdd-HHmmss}.xlsx";
  339 + return new FileStreamResult(ms, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
  340 + { FileDownloadName = fileName };
  341 + }
  342 +
  343 + /// <inheritdoc />
261 344 public Task<UsAppLabelPrintOutputDto> ReprintPrintLogAsync(UsAppLabelReprintInputVo input) =>
262 345 _usAppLabelingAppService.ReprintAsync(input);
263 346  
... ... @@ -774,4 +857,61 @@ public class ReportsAppService : ApplicationService, IReportsAppService
774 857 ms.Position = 0;
775 858 return new FileStreamResult(ms, "application/pdf") { FileDownloadName = fileName };
776 859 }
  860 +
  861 + private sealed class PrintLogExportRow
  862 + {
  863 + public string Id { get; set; } = string.Empty;
  864 +
  865 + public string? LabelCode { get; set; }
  866 +
  867 + public string? ProductName { get; set; }
  868 +
  869 + public string? LabelCategoryName { get; set; }
  870 +
  871 + public string? ProductCategoryName { get; set; }
  872 +
  873 + public decimal Width { get; set; }
  874 +
  875 + public decimal Height { get; set; }
  876 +
  877 + public string? Unit { get; set; }
  878 +
  879 + public string? TemplateName { get; set; }
  880 +
  881 + public string? PrintInputJson { get; set; }
  882 +
  883 + public DateTime? PrintedAt { get; set; }
  884 +
  885 + public string? CreatedBy { get; set; }
  886 +
  887 + public string? LocationId { get; set; }
  888 +
  889 + public string? LocName { get; set; }
  890 +
  891 + public string? LocCode { get; set; }
  892 + }
  893 +
  894 + private static ReportsPrintLogListItemDto MapPrintLogExportRowToListItem(PrintLogExportRow x,
  895 + Dictionary<string, string> userMap)
  896 + {
  897 + var cat = !string.IsNullOrWhiteSpace(x.ProductCategoryName)
  898 + ? x.ProductCategoryName!.Trim()
  899 + : (string.IsNullOrWhiteSpace(x.LabelCategoryName) ? "无" : x.LabelCategoryName.Trim());
  900 + var templateText = FormatTemplateDisplay(x.Width, x.Height, x.Unit, x.TemplateName);
  901 + var locText = FormatLocationText(x.LocName, x.LocCode);
  902 + var printedAt = x.PrintedAt ?? DateTime.MinValue;
  903 + return new ReportsPrintLogListItemDto
  904 + {
  905 + TaskId = x.Id,
  906 + LabelCode = string.IsNullOrWhiteSpace(x.LabelCode) ? "无" : x.LabelCode.Trim(),
  907 + ProductName = string.IsNullOrWhiteSpace(x.ProductName) ? "无" : x.ProductName.Trim(),
  908 + CategoryName = string.IsNullOrWhiteSpace(cat) ? "无" : cat,
  909 + TemplateText = string.IsNullOrWhiteSpace(templateText) ? "无" : templateText,
  910 + PrintedAt = printedAt,
  911 + PrintedByName = ResolveUserName(userMap, x.CreatedBy),
  912 + LocationText = locText,
  913 + LocationId = x.LocationId?.Trim(),
  914 + ExpiryDateText = TryExtractExpiryText(x.PrintInputJson)
  915 + };
  916 + }
777 917 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs
1   -using FoodLabeling.Application.Helpers;
  1 +using System.IO;
2 2 using FoodLabeling.Application.Contracts.Dtos.Common;
3 3 using FoodLabeling.Application.Contracts.Dtos.TeamMember;
4 4 using FoodLabeling.Application.Contracts.IServices;
  5 +using FoodLabeling.Application.Helpers;
  6 +using FoodLabeling.Application.Options;
5 7 using FoodLabeling.Application.Services.DbModels;
6 8 using FoodLabeling.Domain.Entities;
  9 +using Microsoft.AspNetCore.Mvc;
  10 +using Microsoft.Extensions.Options;
  11 +using QuestPDF.Fluent;
  12 +using QuestPDF.Helpers;
  13 +using QuestPDF.Infrastructure;
7 14 using SqlSugar;
8 15 using Volo.Abp;
9 16 using Volo.Abp.Application.Services;
... ... @@ -16,7 +23,7 @@ using Yi.Framework.SqlSugarCore.Abstractions;
16 23 namespace FoodLabeling.Application.Services;
17 24  
18 25 /// <summary>
19   -/// 成员(Team Member/Manager)服务,对外仅在 food-labeling-us 暴露
  26 +/// 成员(Team Member)服务,对外仅在 food-labeling-us 暴露
20 27 /// </summary>
21 28 public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
22 29 {
... ... @@ -24,114 +31,39 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
24 31 private readonly UserManager _userManager;
25 32 private readonly ISqlSugarDbContext _dbContext;
26 33 private readonly IGuidGenerator _guidGenerator;
  34 + private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions;
27 35  
28 36 public TeamMemberAppService(
29 37 ISqlSugarRepository<UserAggregateRoot, Guid> userRepository,
30 38 UserManager userManager,
31 39 ISqlSugarDbContext dbContext,
32   - IGuidGenerator guidGenerator)
  40 + IGuidGenerator guidGenerator,
  41 + IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions)
33 42 {
34 43 _userRepository = userRepository;
35 44 _userManager = userManager;
36 45 _dbContext = dbContext;
37 46 _guidGenerator = guidGenerator;
  47 + _batchImportOptions = batchImportOptions;
38 48 }
39 49  
40   - /// <summary>
41   - /// 成员分页列表(含角色与已分配门店)
42   - /// </summary>
  50 + /// <inheritdoc />
43 51 public async Task<PagedResultWithPageDto<TeamMemberGetListOutputDto>> GetListAsync(TeamMemberGetListInputVo input)
44 52 {
45 53 var pageIndex = PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
46 54 var pageSize = input.MaxResultCount;
47   - var keyword = input.Keyword?.Trim();
48   -
49 55 RefAsync<int> total = 0;
50 56  
51   - // 先按 user 表筛选分页,再批量补齐角色与门店
52   - var users = await _userRepository._DbQueryable
53   - .Where(u => !u.IsDeleted)
54   - .WhereIF(!string.IsNullOrWhiteSpace(keyword),
55   - u => (u.Name != null && u.Name.Contains(keyword!)) ||
56   - u.UserName.Contains(keyword!) ||
57   - (u.Email != null && u.Email.Contains(keyword!)) ||
58   - (u.Phone != null && u.Phone.ToString()!.Contains(keyword!)))
59   - .WhereIF(input.State != null, u => u.State == input.State)
  57 + var query = await BuildFilteredUserQueryAsync(input);
  58 + var users = await query
60 59 .OrderByIF(!string.IsNullOrWhiteSpace(input.Sorting), input.Sorting!)
61 60 .OrderByDescending(u => u.CreationTime)
62 61 .ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
63 62  
64   - var userIds = users.Select(x => x.Id).ToList();
65   - var userIdStrings = userIds.Select(x => x.ToString()).ToList();
66   -
67   - // user-role: 仅取第一个角色(原型表格展示单角色)
68   - var userRolePairs = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity, RoleAggregateRoot>((ur, r) => ur.RoleId == r.Id)
69   - .Where(ur => userIds.Contains(ur.UserId))
70   - .Select((ur, r) => new { ur.UserId, r.Id, r.RoleName })
71   - .ToListAsync();
72   -
73   - var roleMap = userRolePairs
74   - .GroupBy(x => x.UserId)
75   - .ToDictionary(g => g.Key, g => g.FirstOrDefault());
76   -
77   - // user-location
78   - var userLocations = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
79   - .Where(x => !x.IsDeleted)
80   - .WhereIF(!string.IsNullOrWhiteSpace(input.LocationId), x => x.LocationId == input.LocationId)
81   - .Where(x => userIdStrings.Contains(x.UserId))
82   - .ToListAsync();
83   -
84   - // 如果按 LocationId 过滤,需要反向过滤掉无关联的 user
85   - if (!string.IsNullOrWhiteSpace(input.LocationId))
86   - {
87   - var allowedUserIds = userLocations.Select(x => x.UserId).ToHashSet();
88   - users = users.Where(u => allowedUserIds.Contains(u.Id.ToString())).ToList();
89   - }
90   -
91   - var locationIds = userLocations.Select(x => x.LocationId).Distinct().ToList();
92   - var locations = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
93   - .Where(x => !x.IsDeleted)
94   - .WhereIF(locationIds.Count > 0, x => locationIds.Contains(x.Id.ToString()))
95   - .Select(x => new { x.Id, x.LocationCode, x.LocationName })
96   - .ToListAsync();
97   - var locationMap = locations.ToDictionary(x => x.Id.ToString(), x => x);
98   -
99   - var assignedMap = userLocations
100   - .GroupBy(x => x.UserId)
101   - .ToDictionary(
102   - g => g.Key,
103   - g => g.Select(x =>
104   - {
105   - if (locationMap.TryGetValue(x.LocationId, out var loc))
106   - {
107   - return new TeamMemberAssignedLocationDto
108   - {
109   - Id = loc.Id.ToString(),
110   - LocationCode = loc.LocationCode,
111   - LocationName = loc.LocationName
112   - };
113   - }
114   - return null;
115   - }).Where(x => x != null).Cast<TeamMemberAssignedLocationDto>().ToList());
116   -
117   - var items = users.Select(u =>
118   - {
119   - roleMap.TryGetValue(u.Id, out var role);
120   - assignedMap.TryGetValue(u.Id.ToString(), out var assigned);
121   -
122   - return new TeamMemberGetListOutputDto
123   - {
124   - Id = u.Id,
125   - FullName = u.Name ?? string.Empty,
126   - UserName = u.UserName,
127   - Email = u.Email,
128   - Phone = u.Phone,
129   - State = u.State,
130   - RoleId = role?.Id,
131   - RoleName = role?.RoleName,
132   - AssignedLocations = assigned ?? new List<TeamMemberAssignedLocationDto>()
133   - };
134   - }).ToList();
  63 + var items = await MapUsersToOutputAsync(
  64 + users,
  65 + input.LocationId,
  66 + restrictAssignedLocationsToFilter: !string.IsNullOrWhiteSpace(input.LocationId));
135 67  
136 68 var totalCount = (long)total;
137 69 return new PagedResultWithPageDto<TeamMemberGetListOutputDto>
... ... @@ -144,9 +76,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
144 76 };
145 77 }
146 78  
147   - /// <summary>
148   - /// 成员详情(带门店ID列表)
149   - /// </summary>
  79 + /// <inheritdoc />
150 80 public async Task<TeamMemberGetOutputDto> GetAsync(Guid id)
151 81 {
152 82 var user = await _userRepository.GetByIdAsync(id);
... ... @@ -190,9 +120,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
190 120 };
191 121 }
192 122  
193   - /// <summary>
194   - /// 新增成员(同步设置角色与门店)
195   - /// </summary>
  123 + /// <inheritdoc />
196 124 public async Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input)
197 125 {
198 126 if (input.LocationIds is null || input.LocationIds.Count == 0)
... ... @@ -222,9 +150,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
222 150 return await GetAsync(user.Id);
223 151 }
224 152  
225   - /// <summary>
226   - /// 编辑成员(同步设置角色与门店)
227   - /// </summary>
  153 + /// <inheritdoc />
228 154 public async Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input)
229 155 {
230 156 if (input.LocationIds is null || input.LocationIds.Count == 0)
... ... @@ -252,7 +178,6 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
252 178  
253 179 await _userRepository.UpdateAsync(user);
254 180  
255   - // 角色:覆盖式设置(只保留一个)
256 181 if (input.RoleId != null)
257 182 {
258 183 await _userManager.GiveUserSetRoleAsync(new List<Guid> { id }, new List<Guid> { input.RoleId.Value });
... ... @@ -267,9 +192,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
267 192 return await GetAsync(id);
268 193 }
269 194  
270   - /// <summary>
271   - /// 删除成员(逻辑删除 user;并逻辑删除关联表)
272   - /// </summary>
  195 + /// <inheritdoc />
273 196 public async Task DeleteAsync(Guid id)
274 197 {
275 198 var user = await _userRepository.GetByIdAsync(id);
... ... @@ -294,6 +217,393 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
294 217 .ExecuteCommandAsync();
295 218 }
296 219  
  220 + /// <inheritdoc />
  221 + public Task<IActionResult> DownloadTeamMemberImportTemplateAsync()
  222 + {
  223 + var opt = _batchImportOptions.Value;
  224 + var dir = opt.TemplateDirectory?.Trim();
  225 + if (string.IsNullOrWhiteSpace(dir))
  226 + {
  227 + throw new UserFriendlyException("未配置批量导入模板目录 FoodLabeling:BatchImport:TemplateDirectory");
  228 + }
  229 +
  230 + var fileName = opt.TeamMemberTemplateFileName?.Trim();
  231 + if (string.IsNullOrWhiteSpace(fileName))
  232 + {
  233 + throw new UserFriendlyException("未配置模板文件名 FoodLabeling:BatchImport:TeamMemberTemplateFileName");
  234 + }
  235 +
  236 + var fullPath = Path.Combine(dir, fileName);
  237 + if (!File.Exists(fullPath))
  238 + {
  239 + throw new UserFriendlyException($"模板文件不存在:{fullPath}");
  240 + }
  241 +
  242 + var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
  243 + const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
  244 + return Task.FromResult<IActionResult>(new FileStreamResult(stream, contentType)
  245 + {
  246 + FileDownloadName = fileName
  247 + });
  248 + }
  249 +
  250 + /// <inheritdoc />
  251 + public async Task<IActionResult> ExportTeamMembersPdfAsync([FromQuery] TeamMemberGetListInputVo input)
  252 + {
  253 + QuestPDF.Settings.License = LicenseType.Community;
  254 +
  255 + var query = await BuildFilteredUserQueryAsync(input);
  256 + var users = await query
  257 + .OrderByIF(!string.IsNullOrWhiteSpace(input.Sorting), input.Sorting!)
  258 + .OrderByDescending(u => u.CreationTime)
  259 + .ToListAsync();
  260 +
  261 + var rows = await MapUsersToOutputAsync(users, locationFilter: null, restrictAssignedLocationsToFilter: false);
  262 +
  263 + var fileName = $"team-members_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
  264 + var document = Document.Create(container =>
  265 + {
  266 + container.Page(page =>
  267 + {
  268 + page.Margin(22);
  269 + page.DefaultTextStyle(x => x.FontSize(8));
  270 + page.Header().Text("Team Members").SemiBold().FontSize(16);
  271 + page.Content().PaddingTop(8).Table(table =>
  272 + {
  273 + table.ColumnsDefinition(c =>
  274 + {
  275 + c.RelativeColumn(1.4f);
  276 + c.RelativeColumn(1.6f);
  277 + c.RelativeColumn(1.1f);
  278 + c.RelativeColumn(1.1f);
  279 + c.RelativeColumn(2.2f);
  280 + c.RelativeColumn(0.7f);
  281 + });
  282 +
  283 + static IContainer CellHeader(IContainer c) =>
  284 + c.Background(Colors.Grey.Lighten3).Padding(4).DefaultTextStyle(x => x.SemiBold());
  285 +
  286 + table.Cell().Element(CellHeader).Text("Name");
  287 + table.Cell().Element(CellHeader).Text("Email");
  288 + table.Cell().Element(CellHeader).Text("Phone");
  289 + table.Cell().Element(CellHeader).Text("Role");
  290 + table.Cell().Element(CellHeader).Text("Assigned Locations");
  291 + table.Cell().Element(CellHeader).Text("Status");
  292 +
  293 + foreach (var e in rows)
  294 + {
  295 + var locText = e.AssignedLocations.Count == 0
  296 + ? "无"
  297 + : string.Join("; ",
  298 + e.AssignedLocations.Select(a =>
  299 + $"{a.LocationCode} - {a.LocationName}"));
  300 + var status = e.State ? "Active" : "Inactive";
  301 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  302 + .Text(e.FullName ?? string.Empty);
  303 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  304 + .Text(e.Email ?? "无");
  305 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  306 + .Text(e.Phone?.ToString() ?? "无");
  307 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  308 + .Text(string.IsNullOrWhiteSpace(e.RoleName) ? "无" : e.RoleName);
  309 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  310 + .Text(locText);
  311 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
  312 + .Text(status);
  313 + }
  314 + });
  315 + });
  316 + });
  317 +
  318 + var stream = new MemoryStream();
  319 + document.GeneratePdf(stream);
  320 + stream.Position = 0;
  321 + return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
  322 + }
  323 +
  324 + /// <inheritdoc />
  325 + public async Task<TeamMemberBatchImportResultDto> ImportTeamMembersBatchAsync(
  326 + [FromForm] TeamMemberBatchImportInputVo input)
  327 + {
  328 + if (input?.File is null || input.File.Length == 0)
  329 + {
  330 + throw new UserFriendlyException("请上传 Excel 文件(form 字段名:file)");
  331 + }
  332 +
  333 + var opt = _batchImportOptions.Value;
  334 + if (input.File.Length > opt.MaxUploadBytes)
  335 + {
  336 + throw new UserFriendlyException($"文件过大,最大允许 {opt.MaxUploadBytes / 1024 / 1024} MB");
  337 + }
  338 +
  339 + var ext = Path.GetExtension(input.File.FileName)?.ToLowerInvariant();
  340 + if (ext != ".xlsx")
  341 + {
  342 + throw new UserFriendlyException("仅支持 .xlsx 格式的 Excel 文件");
  343 + }
  344 +
  345 + var roleMap = await BuildRoleNameToIdMapAsync();
  346 + await using var uploadStream = input.File.OpenReadStream();
  347 + var parseErrors = new List<TeamMemberBatchImportErrorDto>();
  348 + var rows = TeamMemberBatchExcelHelper.ParseImportWorkbook(
  349 + uploadStream,
  350 + opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows,
  351 + roleMap,
  352 + opt.TeamMemberImportDefaultPassword?.Trim() ?? string.Empty,
  353 + out var headerErrors);
  354 + parseErrors.AddRange(headerErrors);
  355 +
  356 + var result = new TeamMemberBatchImportResultDto();
  357 + if (rows.Count == 0 && parseErrors.Count > 0)
  358 + {
  359 + result.Errors = parseErrors;
  360 + result.FailCount = parseErrors.Count;
  361 + return result;
  362 + }
  363 +
  364 + foreach (var (rowNum, vo) in rows)
  365 + {
  366 + try
  367 + {
  368 + vo.LocationIds = await ResolveLocationIdsFromImportTokensAsync(vo.LocationIds);
  369 + await CreateAsync(vo);
  370 + result.SuccessCount++;
  371 + }
  372 + catch (UserFriendlyException ex)
  373 + {
  374 + result.FailCount++;
  375 + result.Errors.Add(new TeamMemberBatchImportErrorDto
  376 + {
  377 + RowNumber = rowNum,
  378 + UserName = vo.UserName,
  379 + Message = ex.Message
  380 + });
  381 + }
  382 + }
  383 +
  384 + result.Errors.InsertRange(0, parseErrors);
  385 + return result;
  386 + }
  387 +
  388 + /// <inheritdoc />
  389 + public async Task<TeamMemberBulkUpdateResultDto> UpdateTeamMembersBulkAsync(
  390 + [FromBody] TeamMemberBulkUpdateInputVo input)
  391 + {
  392 + if (input?.Items is null || input.Items.Count == 0)
  393 + {
  394 + throw new UserFriendlyException("请至少提交一条编辑数据(items 不能为空)");
  395 + }
  396 +
  397 + var opt = _batchImportOptions.Value;
  398 + var maxItems = opt.MaxBulkUpdateItems <= 0 ? 500 : opt.MaxBulkUpdateItems;
  399 + if (input.Items.Count > maxItems)
  400 + {
  401 + throw new UserFriendlyException($"单次批量编辑最多允许 {maxItems} 条,请分批提交");
  402 + }
  403 +
  404 + var effectiveCount = input.Items.Count(static x => x is not null && x.Id != Guid.Empty);
  405 + if (effectiveCount == 0)
  406 + {
  407 + throw new UserFriendlyException("没有有效的成员 Id(请为待保存行填写 id)");
  408 + }
  409 +
  410 + var result = new TeamMemberBulkUpdateResultDto();
  411 + for (var i = 0; i < input.Items.Count; i++)
  412 + {
  413 + var item = input.Items[i];
  414 + if (item is null || item.Id == Guid.Empty)
  415 + {
  416 + continue;
  417 + }
  418 +
  419 + try
  420 + {
  421 + await UpdateAsync(item.Id, item);
  422 + result.SuccessCount++;
  423 + }
  424 + catch (UserFriendlyException ex)
  425 + {
  426 + result.FailCount++;
  427 + result.Errors.Add(new TeamMemberBulkUpdateErrorDto
  428 + {
  429 + RowNumber = i + 1,
  430 + Id = item.Id,
  431 + Message = ex.Message
  432 + });
  433 + }
  434 + }
  435 +
  436 + return result;
  437 + }
  438 +
  439 + private async Task<Dictionary<string, Guid>> BuildRoleNameToIdMapAsync()
  440 + {
  441 + var roles = await _dbContext.SqlSugarClient.Queryable<RoleAggregateRoot>()
  442 + .Where(r => !r.IsDeleted)
  443 + .Select(r => new { r.Id, r.RoleName })
  444 + .ToListAsync();
  445 +
  446 + return roles
  447 + .Where(r => !string.IsNullOrWhiteSpace(r.RoleName))
  448 + .GroupBy(r => TeamMemberBatchExcelHelper.NormalizeRoleKey(r.RoleName!))
  449 + .ToDictionary(g => g.Key, g => g.First().Id);
  450 + }
  451 +
  452 + private async Task<List<string>> ResolveLocationIdsFromImportTokensAsync(List<string> tokens)
  453 + {
  454 + var result = new List<string>();
  455 + foreach (var raw in tokens)
  456 + {
  457 + var s = raw.Trim();
  458 + if (string.IsNullOrEmpty(s))
  459 + {
  460 + continue;
  461 + }
  462 +
  463 + var idx = s.IndexOf(" -", StringComparison.Ordinal);
  464 + var key = idx > 0 ? s[..idx].Trim() : s.Trim();
  465 + if (Guid.TryParse(key, out var gid))
  466 + {
  467 + var byId = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  468 + .Where(x => !x.IsDeleted && x.Id == gid)
  469 + .FirstAsync();
  470 + if (byId is null)
  471 + {
  472 + throw new UserFriendlyException($"无效门店 Id:{key}");
  473 + }
  474 +
  475 + result.Add(byId.Id.ToString());
  476 + continue;
  477 + }
  478 +
  479 + var byCode = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  480 + .Where(x => !x.IsDeleted && x.LocationCode == key)
  481 + .FirstAsync();
  482 + if (byCode is null)
  483 + {
  484 + throw new UserFriendlyException($"未找到门店编码:{key}");
  485 + }
  486 +
  487 + result.Add(byCode.Id.ToString());
  488 + }
  489 +
  490 + return result.Distinct().ToList();
  491 + }
  492 +
  493 + private async Task<ISugarQueryable<UserAggregateRoot>> BuildFilteredUserQueryAsync(TeamMemberGetListInputVo input)
  494 + {
  495 + var keyword = input.Keyword?.Trim();
  496 + var query = _userRepository._DbQueryable
  497 + .Where(u => !u.IsDeleted)
  498 + .WhereIF(!string.IsNullOrWhiteSpace(keyword),
  499 + u => (u.Name != null && u.Name.Contains(keyword!)) ||
  500 + u.UserName.Contains(keyword!) ||
  501 + (u.Email != null && u.Email.Contains(keyword!)) ||
  502 + (u.Phone != null && u.Phone.ToString()!.Contains(keyword!)))
  503 + .WhereIF(input.State != null, u => u.State == input.State);
  504 +
  505 + if (input.RoleId != null)
  506 + {
  507 + var userIds = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity>()
  508 + .Where(ur => ur.RoleId == input.RoleId.Value)
  509 + .Select(ur => ur.UserId)
  510 + .ToListAsync();
  511 + query = query.Where(u => userIds.Contains(u.Id));
  512 + }
  513 +
  514 + if (!string.IsNullOrWhiteSpace(input.LocationId))
  515 + {
  516 + var locId = input.LocationId.Trim();
  517 + var userIdStrs = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  518 + .Where(x => !x.IsDeleted && x.LocationId == locId)
  519 + .Select(x => x.UserId)
  520 + .ToListAsync();
  521 + var allowed = new HashSet<string>(userIdStrs);
  522 + query = query.Where(u => allowed.Contains(u.Id.ToString()));
  523 + }
  524 +
  525 + return query;
  526 + }
  527 +
  528 + private async Task<List<TeamMemberGetListOutputDto>> MapUsersToOutputAsync(
  529 + List<UserAggregateRoot> users,
  530 + string? locationFilter,
  531 + bool restrictAssignedLocationsToFilter)
  532 + {
  533 + if (users.Count == 0)
  534 + {
  535 + return new List<TeamMemberGetListOutputDto>();
  536 + }
  537 +
  538 + var userIds = users.Select(x => x.Id).ToList();
  539 + var userIdStrings = userIds.Select(x => x.ToString()).ToList();
  540 +
  541 + var userRolePairs = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity, RoleAggregateRoot>((ur, r) => ur.RoleId == r.Id)
  542 + .Where(ur => userIds.Contains(ur.UserId))
  543 + .Select((ur, r) => new { ur.UserId, r.Id, r.RoleName })
  544 + .ToListAsync();
  545 +
  546 + var roleMap = userRolePairs
  547 + .GroupBy(x => x.UserId)
  548 + .ToDictionary(g => g.Key, g => g.FirstOrDefault());
  549 +
  550 + var userLocQuery = _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  551 + .Where(x => !x.IsDeleted)
  552 + .Where(x => userIdStrings.Contains(x.UserId));
  553 + if (restrictAssignedLocationsToFilter && !string.IsNullOrWhiteSpace(locationFilter))
  554 + {
  555 + userLocQuery = userLocQuery.Where(x => x.LocationId == locationFilter.Trim());
  556 + }
  557 +
  558 + var userLocations = await userLocQuery.ToListAsync();
  559 +
  560 + var locationIds = userLocations.Select(x => x.LocationId).Distinct().ToList();
  561 + var locations = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  562 + .Where(x => !x.IsDeleted)
  563 + .WhereIF(locationIds.Count > 0, x => locationIds.Contains(x.Id.ToString()))
  564 + .Select(x => new { x.Id, x.LocationCode, x.LocationName })
  565 + .ToListAsync();
  566 + var locationMap = locations.ToDictionary(x => x.Id.ToString(), x => x);
  567 +
  568 + var assignedMap = userLocations
  569 + .GroupBy(x => x.UserId)
  570 + .ToDictionary(
  571 + g => g.Key,
  572 + g => g.Select(x =>
  573 + {
  574 + if (locationMap.TryGetValue(x.LocationId, out var loc))
  575 + {
  576 + return new TeamMemberAssignedLocationDto
  577 + {
  578 + Id = loc.Id.ToString(),
  579 + LocationCode = loc.LocationCode,
  580 + LocationName = loc.LocationName
  581 + };
  582 + }
  583 +
  584 + return null;
  585 + }).Where(x => x != null).Cast<TeamMemberAssignedLocationDto>().ToList());
  586 +
  587 + return users.Select(u =>
  588 + {
  589 + roleMap.TryGetValue(u.Id, out var role);
  590 + assignedMap.TryGetValue(u.Id.ToString(), out var assigned);
  591 +
  592 + return new TeamMemberGetListOutputDto
  593 + {
  594 + Id = u.Id,
  595 + FullName = u.Name ?? string.Empty,
  596 + UserName = u.UserName,
  597 + Email = u.Email,
  598 + Phone = u.Phone,
  599 + State = u.State,
  600 + RoleId = role?.Id,
  601 + RoleName = role?.RoleName,
  602 + AssignedLocations = assigned ?? new List<TeamMemberAssignedLocationDto>()
  603 + };
  604 + }).ToList();
  605 + }
  606 +
297 607 private async Task UpsertUserLocationsAsync(Guid userId, List<string> locationIds)
298 608 {
299 609 var now = DateTime.Now;
... ... @@ -301,7 +611,6 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
301 611 var wanted = locationIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToList();
302 612 var currentUserId = CurrentUser?.Id?.ToString();
303 613  
304   - // 校验门店存在且未删除
305 614 var validCount = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
306 615 .Where(x => !x.IsDeleted)
307 616 .Where(x => wanted.Contains(x.Id.ToString()))
... ... @@ -318,7 +627,6 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
318 627 var existingActive = existing.Where(x => !x.IsDeleted).ToList();
319 628 var existingActiveSet = existingActive.Select(x => x.LocationId).ToHashSet();
320 629  
321   - // 需要删除的(逻辑删除)
322 630 var toDelete = existingActive.Where(x => !wanted.Contains(x.LocationId)).ToList();
323 631 if (toDelete.Count > 0)
324 632 {
... ... @@ -334,7 +642,6 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
334 642 .ExecuteCommandAsync();
335 643 }
336 644  
337   - // 需要新增的
338 645 var toInsert = wanted.Where(x => !existingActiveSet.Contains(x)).ToList();
339 646 if (toInsert.Count > 0)
340 647 {
... ... @@ -353,4 +660,3 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
353 660 }
354 661 }
355 662 }
356   -
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
... ... @@ -52,7 +52,7 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
52 52 /// <remarks>
53 53 /// L1 标签分类 fl_label_category(含 buttonAppearance;COLOR/IMAGE 展示值在 categoryPhotoUrl);仅对当前门店可用:ALL 或 SPECIFIED 且在 fl_label_category_location;
54 54 /// L2 产品分类 fl_product.CategoryId join fl_product_category(同上,展示值在 categoryPhotoUrl);
55   - /// L3 产品;L4 与该门店、该标签分类、该产品关联的标签实例(fl_label + fl_label_type)。
  55 + /// L3 产品卡片:按「产品 + 标签模板」拆分(同一 productId、不同 fl_label.TemplateId 为多张卡);L4 为该卡下与门店、标签分类、该产品、该模板关联的标签实例(fl_label + fl_label_type)。
56 56 /// L2 仅包含对当前门店可用的类别:AvailabilityType=ALL,或 SPECIFIED 且在 fl_product_category_location 存在该门店记录;
57 57 /// 未归类或分类行未关联到 fl_product_category 时仍归入「无」节点。
58 58 /// </remarks>
... ... @@ -102,7 +102,8 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
102 102 LabelTypeId = t.Id,
103 103 TypeName = t.TypeName,
104 104 TypeOrderNum = t.OrderNum,
105   - LabelCode = l.LabelCode,
  105 + LabelCode = l.LabelCode ?? string.Empty,
  106 + TemplateId = tpl.Id,
106 107 TemplateCode = tpl.TemplateCode,
107 108 TemplateWidth = tpl.Width,
108 109 TemplateHeight = tpl.Height,
... ... @@ -175,7 +176,10 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
175 176  
176 177 foreach (var g2 in byL2)
177 178 {
178   - var productsGrouped = g2.GroupBy(x => x.ProductId).OrderBy(pg => pg.First().ProductName);
  179 + var productsGrouped = g2
  180 + .GroupBy(x => new { x.ProductId, x.TemplateId })
  181 + .OrderBy(pg => pg.First().ProductName)
  182 + .ThenBy(pg => pg.Key.TemplateId);
179 183 var appearance = string.IsNullOrWhiteSpace(g2.Key.ButtonAppearance)
180 184 ? "TEXT"
181 185 : g2.Key.ButtonAppearance.Trim();
... ... @@ -208,10 +212,17 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
208 212 var subtitle = string.IsNullOrWhiteSpace(first.ProductCode?.Trim())
209 213 ? "无"
210 214 : first.ProductCode!.Trim();
  215 + var templateLabelSizeText = FormatLabelSize(
  216 + first.TemplateWidth,
  217 + first.TemplateHeight,
  218 + first.TemplateUnit);
211 219  
212 220 l2.Products.Add(new UsAppLabelingProductNodeDto
213 221 {
214 222 ProductId = first.ProductId,
  223 + TemplateId = first.TemplateId,
  224 + TemplateCode = first.TemplateCode,
  225 + TemplateLabelSizeText = templateLabelSizeText,
215 226 ProductName = first.ProductName ?? string.Empty,
216 227 ProductCode = first.ProductCode ?? string.Empty,
217 228 ProductImageUrl = first.ProductImageUrl,
... ... @@ -957,6 +968,8 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
957 968  
958 969 public string LabelCode { get; set; } = string.Empty;
959 970  
  971 + public string TemplateId { get; set; } = string.Empty;
  972 +
960 973 public string? TemplateCode { get; set; }
961 974  
962 975 public decimal TemplateWidth { get; set; }
... ...
项目相关文档/Account-Management-批量导入模板/Location-Manager-批量导入模板.csv 0 → 100644
  1 +Company,Region,Location ID,Location Name,Street,City,State,Country,Zip Code,Phone,Email,Latitude,Longitude,Active
  2 +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
  3 +,,,,,,,,,,,,,
... ...
项目相关文档/Account-Management-批量导入模板/Product-Manager-批量导入模板.xlsx 0 → 100644
No preview for this file type
项目相关文档/Account-Management-批量导入模板/Team-Member-批量导入模板.csv 0 → 100644
  1 +Name,User Name (Login),Password,Email,Phone,Role Id,Role Name (仅说明勿删),Assigned Location Ids,Status
  2 +John Doe,john.doe,ChangeMe123!,john@123.com,789654444,"(必填)从系统角色列表复制 Role Id","Staff","门店Guid1;门店Guid2",TRUE
  3 +,,,,,,,,,
... ...
项目相关文档/批量导入导出接口说明.md 0 → 100644
  1 +# 美国版 · 批量导入 / 批量导出(Excel·PDF)/ 下载模板 / 批量编辑 — 接口汇总
  2 +
  3 +本文档集中维护 **Account Management**、**Reports** 及相关模块的「下载 Excel 模板」「批量导出(Excel 或 PDF)」「批量导入 Excel」以及 **网格「保存全部」式批量编辑(JSON)** 等接口。**单条 CRUD、分页列表**仍以各业务模块说明为准(如门店见 `门店(Location)接口对接说明.md`)。
  4 +
  5 +---
  6 +
  7 +## 目录
  8 +
  9 +| 章节 | 内容 |
  10 +|------|------|
  11 +| [公共约定](#公共约定) | 基址、鉴权、Swagger、通用注意事项 |
  12 +| [共享配置](#共享配置) | `appsettings` 中 `FoodLabeling:BatchImport` |
  13 +| [1 Location Manager(门店)](#1-location-manager门店) | 下载模板 / Excel 导出 / 导入 / 批量编辑 |
  14 +| [2 Team Member(成员)](#2-team-member成员) | 下载模板 / **PDF 全量导出** / Excel 导入 / 批量编辑 |
  15 +| [3 Products(菜单-产品)](#3-products菜单-产品) | 下载模板 / Excel 全量导出 / Excel 导入 / 批量编辑 |
  16 +| [4 后续模块(预留)](#4-后续模块预留) | 新接口在此追加小节 |
  17 +| [5 Account Management(Company / Region)](#5-account-managementcompany-region) | **PDF 全量导出**(Company、Region 页签) |
  18 +| [6 Reports — Print Log(Excel)](#6-reports--print-logexcel) | **Excel 全量导出**(Print Log 页签) |
  19 +| [附录 curl 模板](#附录-curl-模板) | 登录、Location / Team Member / Products / Company / Region / Reports 调用示例 |
  20 +
  21 +---
  22 +
  23 +## 公共约定
  24 +
  25 +- **宿主**:美国版后端 `Yi.Abp.Web`;本地 Swagger 示例:`http://localhost:19001/swagger`。
  26 +- **路由前缀**:约定式控制器 `RootPath` 为 **`api/app`**(与 ABP 实际配置一致)。
  27 +- **Swagger 分组**:**「食品标签-美国版接口」**;具体路径以 Swagger 展示为准(下表为常见命名,联调时请以 Swagger 为准)。
  28 +- **鉴权**:与其它业务接口相同,请求头携带登录接口返回的 **`data.token` 完整值**(已含 `Bearer ` 前缀),示例:`Authorization: {data.token}`。
  29 +- **文件类响应**:`Content-Type` 多为 `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`(`.xlsx`)。
  30 +- **导入类请求**:统一使用 **`multipart/form-data`**,文件字段名以各接口说明为准(Location 为 **`file`**)。
  31 +- **批量编辑类请求**:使用 **`application/json`**,一次提交多行(与前端表格「保存全部」对齐)。
  32 +- **导出类响应**:Location 与 **Products(菜单-产品)**、**Reports — Print Log** 为 **Excel 全量**;**Team Member**、**Account Management 的 Company / Region**、**Reports — Label Report** 等为 **PDF**(见各小节);数据量极大时请注意服务端内存与响应耗时。
  33 +
  34 +---
  35 +
  36 +## 共享配置
  37 +
  38 +配置节全名:`FoodLabeling:BatchImport`(绑定类:`FoodLabelingBatchImportOptions`,在 `FoodLabelingApplicationModule` 中注册)。
  39 +
  40 +| 配置项 | 说明 |
  41 +|--------|------|
  42 +| `TemplateDirectory` | 服务器上存放**批量导入模板**的目录(生产示例:`/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles`) |
  43 +| `LocationTemplateFileName` | Location 模板文件名(默认:`Location-Manager-批量导入模板.xlsx`) |
  44 +| `TeamMemberTemplateFileName` | Team Member 模板文件名(默认:`Team-Member-批量导入模板.xlsx`) |
  45 +| `ProductTemplateFileName` | Product(菜单-产品)模板文件名(默认:`Product-Manager-批量导入模板.xlsx`) |
  46 +| `TeamMemberImportDefaultPassword` | Team Member 批量导入时,Excel 未填 Password 列则使用的默认初始密码 |
  47 +| `MaxImportRows` | 单次导入最多数据行数(默认 5000) |
  48 +| `MaxUploadBytes` | 上传 Excel 最大字节数(默认 10MB) |
  49 +| `MaxBulkUpdateItems` | 单次「批量编辑」请求中 **`items` 数组最大长度**(默认 500;含占位空行,与前端网格行数一致) |
  50 +
  51 +后续若增加其它模块模板文件名等,可在此表同一节下扩展配置项说明(并与 `appsettings`、Options 类保持一致)。
  52 +
  53 +---
  54 +
  55 +## 1 Location Manager(门店)
  56 +
  57 +**应用服务**:`LocationAppService`(模块 `food-labeling-us`)。
  58 +
  59 +**列表筛选字段**(导出与列表对齐时):`Sorting`、`Keyword`、`Partner`、`GroupName`、`State` — 含义与分页列表一致,详见 `门店(Location)接口对接说明.md` 接口 1。
  60 +
  61 +### 1.1 下载批量导入模板
  62 +
  63 +| 项目 | 说明 |
  64 +|------|------|
  65 +| 方法 | `DownloadLocationImportTemplateAsync` |
  66 +| HTTP | `GET` |
  67 +| 常见路径 | `/api/app/location/download-location-import-template` |
  68 +| 作用 | 从 `TemplateDirectory` 读取 `LocationTemplateFileName` 指向的文件并作为附件下载 |
  69 +| 失败常见原因 | 未配置目录、文件名、或服务器上文件不存在 |
  70 +
  71 +### 1.2 批量导出 Excel
  72 +
  73 +| 项目 | 说明 |
  74 +|------|------|
  75 +| 方法 | `ExportLocationsExcelAsync` |
  76 +| HTTP | `GET` |
  77 +| 常见路径 | `/api/app/location/export-locations-excel` |
  78 +| Query | 与门店列表筛选一致:`Sorting`、`Keyword`、`Partner`、`GroupName`、`State` |
  79 +| 数据范围 | **全量**:符合筛选条件的全部记录;**不使用**请求中的 `SkipCount` / `MaxResultCount`(与列表分页无关) |
  80 +| 排序 | 与列表一致:有 `Sorting` 则按其排序,否则默认 `CreationTime` 降序 |
  81 +| 响应文件名示例 | `locations-export-yyyyMMdd-HHmmss.xlsx` |
  82 +
  83 +### 1.3 批量导入 Excel
  84 +
  85 +| 项目 | 说明 |
  86 +|------|------|
  87 +| 方法 | `ImportLocationsBatchAsync` |
  88 +| HTTP | `POST` |
  89 +| Content-Type | `multipart/form-data` |
  90 +| 常见路径 | `/api/app/location/import-locations-batch` |
  91 +| 表单字段 | **`file`**:仅支持 `.xlsx` |
  92 +| 返回类型 | `LocationBatchImportResultDto`(JSON) |
  93 +
  94 +**`LocationBatchImportResultDto` 字段**
  95 +
  96 +| 字段 | 说明 |
  97 +|------|------|
  98 +| `SuccessCount` | 成功新增条数 |
  99 +| `FailCount` | 失败条数(含解析错误与逐行业务校验失败) |
  100 +| `SkippedEmptyRows` | 预留,当前一般为 `0` |
  101 +| `Errors` | `RowNumber`、`LocationCode`、`Message` |
  102 +
  103 +**解析与业务摘要**
  104 +
  105 +- 表头须能识别 **Location ID**(或同义列);建议使用官方模板。
  106 +- **必填**:Location ID(`LocationCode`)、Location Name(与单条新增一致)。
  107 +- **可选**:Company/Region、地址、电话、邮箱、经纬度、`Active` 等;经纬度为空则 `null`,有值则校验格式。
  108 +
  109 +### 1.4 批量编辑(网格「保存全部」)
  110 +
  111 +对应前端在 **Location Manager** 进入批量编辑页后,将多行修改一次性提交;**每行通过主键 `id` 定位记录**,可编辑字段与单条 **`PUT /api/app/location/{id}`** 的 body(`LocationUpdateInputVo`)一致。**不修改 Location ID(`LocationCode`)**(与单条更新接口一致)。
  112 +
  113 +| 项目 | 说明 |
  114 +|------|------|
  115 +| 方法 | `UpdateLocationsBulkAsync` |
  116 +| HTTP | `POST` |
  117 +| Content-Type | `application/json` |
  118 +| 常见路径 | `/api/app/location/update-locations-bulk` |
  119 +| Body | `LocationBulkUpdateInputVo` |
  120 +
  121 +**请求体 `LocationBulkUpdateInputVo`**
  122 +
  123 +| 字段 | 类型 | 说明 |
  124 +|------|------|------|
  125 +| `items` | 数组 | 每一元素为 `LocationBulkUpdateItemVo` |
  126 +
  127 +**`LocationBulkUpdateItemVo`(单行)**
  128 +
  129 +| 字段 | 说明 |
  130 +|------|------|
  131 +| `id` | **Guid**,列表接口返回的门店主键;为 **`00000000-0000-0000-0000-000000000000`** 或未填写的占位行将被**忽略**(便于与「至少 10 行空行」类 UI 对齐) |
  132 +| `partner` | 可选,Company |
  133 +| `groupName` | 可选,Region |
  134 +| `locationName` | **必填**(与单条更新校验一致) |
  135 +| `street` / `city` / `stateCode` / `country` / `zipCode` / `phone` / `email` | 可选 |
  136 +| `latitude` / `longitude` | 可选,decimal |
  137 +| `state` | 是否启用,默认 `true` |
  138 +
  139 +**返回 `LocationBulkUpdateResultDto`**
  140 +
  141 +| 字段 | 说明 |
  142 +|------|------|
  143 +| `SuccessCount` | 成功更新条数 |
  144 +| `FailCount` | 失败条数 |
  145 +| `Errors` | `LocationBulkUpdateErrorDto`:`rowNumber`(在 **`items` 数组中的序号,从 1 开始**)、`id`、`message` |
  146 +
  147 +**行为说明**
  148 +
  149 +- **逐条提交**:内部对每一有效行调用与单条更新相同的业务逻辑;**一行失败不影响其它行**。
  150 +- **整单校验**:`items` 为空、超过 `MaxBulkUpdateItems`、或没有任何有效 `id` 时返回 **400** 类业务错误(`UserFriendlyException`)。
  151 +- **JSON 命名**:与项目其它接口一致,一般为 **camelCase**(以实际 JSON 序列化配置为准)。
  152 +
  153 +---
  154 +
  155 +## 2 Team Member(成员)
  156 +
  157 +**应用服务**:`TeamMemberAppService`(模块 `food-labeling-us`;前端常见路径前缀 **`/team-member`**)。
  158 +
  159 +**列表筛选字段**(导出与列表对齐):`Keyword`、`RoleId`、`LocationId`、`State`、`Sorting` — 与成员分页列表一致(`LocationId` 为门店主键字符串,与 `UserLocation.LocationId` 一致)。
  160 +
  161 +### 2.1 下载批量导入模板
  162 +
  163 +| 项目 | 说明 |
  164 +|------|------|
  165 +| 方法 | `DownloadTeamMemberImportTemplateAsync` |
  166 +| HTTP | `GET` |
  167 +| 常见路径 | `/api/app/team-member/download-team-member-import-template` |
  168 +| 作用 | 从 `TemplateDirectory` 读取 `TeamMemberTemplateFileName` 指向的 xlsx 并下载 |
  169 +
  170 +### 2.2 批量导出 PDF(全量)
  171 +
  172 +| 项目 | 说明 |
  173 +|------|------|
  174 +| 方法 | `ExportTeamMembersPdfAsync` |
  175 +| HTTP | `GET` |
  176 +| 常见路径 | `/api/app/team-member/export-team-members-pdf` |
  177 +| Query | 与成员列表筛选一致:`Keyword`、`RoleId`、`LocationId`、`State`、`Sorting` |
  178 +| 数据范围 | **全量**:符合筛选条件的全部成员;**不使用** `SkipCount` / `MaxResultCount` |
  179 +| 排序 | 有 `Sorting` 则按其排序,否则按创建时间降序 |
  180 +| 响应 | `Content-Type: application/pdf`,文件名示例 `team-members_yyyy-MM-dd_HH-mm-ss.pdf` |
  181 +| PDF 列 | Name、Email、Phone、Role、Assigned Locations(多门店以分号拼接)、Status(Active/Inactive) |
  182 +
  183 +**说明**:PDF 中「Assigned Locations」展示该成员**全部**已分配门店(不受列表按门店筛选时「仅显示命中门店」的收缩影响),便于导出后审阅完整权限。
  184 +
  185 +### 2.3 批量导入 Excel
  186 +
  187 +| 项目 | 说明 |
  188 +|------|------|
  189 +| 方法 | `ImportTeamMembersBatchAsync` |
  190 +| HTTP | `POST` |
  191 +| Content-Type | `multipart/form-data` |
  192 +| 常见路径 | `/api/app/team-member/import-team-members-batch` |
  193 +| 表单字段 | **`file`**,仅 `.xlsx` |
  194 +| 返回 | `TeamMemberBatchImportResultDto`:`successCount`、`failCount`、`errors`(`rowNumber`、`userName`、`message`) |
  195 +
  196 +**表头识别(摘要)**
  197 +
  198 +- **必填列**:`Name`(或 FullName)、`Email`;**Role**;**Assigned Locations**(至少一条)。
  199 +- **可选列**:`UserName` / `Login`(不填则登录账号用 Email)、`Password`(不填则用配置 **`TeamMemberImportDefaultPassword`**)、`Phone`、`Status`。
  200 +- **Assigned Locations**:多个门店可用 **`;`**、`|`、换行、中文 **`,`** 分隔;支持 `33333 - Central Park Store`(取 **` -`** 前为门店编码或 Guid)。
  201 +- **Role**:与系统 **`Role.RoleName`** 一致(忽略大小写与中间空格);未匹配则该行失败。
  202 +
  203 +内部对每行调用与单条创建相同的业务逻辑;**单行失败不影响其它行**。
  204 +
  205 +### 2.4 批量编辑(网格「保存全部」)
  206 +
  207 +| 项目 | 说明 |
  208 +|------|------|
  209 +| 方法 | `UpdateTeamMembersBulkAsync` |
  210 +| HTTP | `POST` |
  211 +| Content-Type | `application/json` |
  212 +| 常见路径 | `/api/app/team-member/update-team-members-bulk` |
  213 +| Body | `TeamMemberBulkUpdateInputVo`:`items` 为 `TeamMemberBulkUpdateItemVo` 数组 |
  214 +
  215 +每行含 **`id`(成员 Guid)** 及与单条 **`PUT /api/app/team-member/{id}`** 相同的字段(`password` 可空表示不改密码)。`id` 为全零 GUID 的项忽略。整单规则与 Location 批量编辑相同(`MaxBulkUpdateItems`、至少一条有效 `id` 等)。
  216 +
  217 +**返回** `TeamMemberBulkUpdateResultDto`:`successCount`、`failCount`、`errors`(`rowNumber`、`id`、`message`)。
  218 +
  219 +---
  220 +
  221 +## 3 Products(菜单-产品)
  222 +
  223 +**应用服务**:`ProductAppService`(模块 `food-labeling-us`;前端常见路径前缀 **`/product`**)。
  224 +
  225 +**列表筛选字段**(导出与列表对齐):`Keyword`、`State`、`Sorting` — 与产品分页列表一致(`Keyword` 匹配产品编码、名称、分类名称)。
  226 +
  227 +### 3.1 下载批量导入模板
  228 +
  229 +| 项目 | 说明 |
  230 +|------|------|
  231 +| 方法 | `DownloadProductImportTemplateAsync` |
  232 +| HTTP | `GET` |
  233 +| 常见路径 | `/api/app/product/download-product-import-template` |
  234 +| 作用 | 从 `TemplateDirectory` 读取 `ProductTemplateFileName` 指向的 xlsx 并下载(工作表名一般为 `Products`) |
  235 +
  236 +### 3.2 批量导出 Excel(全量)
  237 +
  238 +| 项目 | 说明 |
  239 +|------|------|
  240 +| 方法 | `ExportProductsExcelAsync` |
  241 +| HTTP | `GET` |
  242 +| 常见路径 | `/api/app/product/export-products-excel` |
  243 +| Query | 与产品列表筛选一致:`Keyword`、`State`、`Sorting` |
  244 +| 数据范围 | **全量**:符合筛选条件的全部产品;**不使用** `SkipCount` / `MaxResultCount` |
  245 +| 排序 | 有 `Sorting` 则按其排序,否则默认按 `ProductName` 降序 |
  246 +| 响应文件名示例 | `products-export-yyyyMMdd-HHmmss.xlsx` |
  247 +| 列(与导入模板一致) | **Location**(多门店英文逗号拼接门店名称)、**Product Category**(分类名称)、**Product**(产品名称)、**Product Code**(产品编码;可为空则导出为空单元格) |
  248 +
  249 +### 3.3 批量导入 Excel
  250 +
  251 +| 项目 | 说明 |
  252 +|------|------|
  253 +| 方法 | `ImportProductsBatchAsync` |
  254 +| HTTP | `POST` |
  255 +| Content-Type | `multipart/form-data` |
  256 +| 常见路径 | `/api/app/product/import-products-batch` |
  257 +| 表单字段 | **`file`**,仅 `.xlsx` |
  258 +| 返回 | `ProductBatchImportResultDto`:`successCount`、`failCount`、`errors`(`rowNumber`、`productName`、`message`) |
  259 +
  260 +**表头识别(摘要)**
  261 +
  262 +- **必填列**:`Product Category`(分类**名称**,与 `fl_product_category.CategoryName` 匹配,忽略大小写;若同名多条则该行失败)、`Product`(产品名称)。
  263 +- **可选列**:`Location`(多门店可用英文逗号 **`,`**、中文逗号、分号、竖线、换行分隔;每个片段:若为 **Guid** 则按门店主键;否则按 **Location Code** 精确匹配,或按 **Location Name** 不区分大小写匹配;**同一片段匹配到多条门店**则该行失败)、`Product Code`(可空,空则创建时由后端生成唯一编码,与单条创建一致)。
  264 +- **不在模板中的字段**:产品主键、启用状态由后端处理;导入创建的产品 **`state` 恒为 `true`(启用)**;`productImageUrl` 不通过本导入写入。
  265 +
  266 +内部对每行调用与单条 **`POST` 创建产品** 相同的业务逻辑(含门店关联写入);**单行失败不影响其它行**。
  267 +
  268 +### 3.4 批量编辑(网格「保存全部」)
  269 +
  270 +| 项目 | 说明 |
  271 +|------|------|
  272 +| 方法 | `UpdateProductsBulkAsync` |
  273 +| HTTP | `POST` |
  274 +| Content-Type | `application/json` |
  275 +| 常见路径 | `/api/app/product/update-products-bulk` |
  276 +| Body | `ProductBulkUpdateInputVo`:`items` 为 `ProductBulkUpdateItemVo` 数组 |
  277 +
  278 +每行含 **`id`(产品主键字符串,与列表/详情返回的 `id` 一致)** 及与单条 **`PUT /api/app/product/{id}`** 相同的 body 字段(`ProductUpdateInputVo` / `ProductCreateInputVo` 形状:`productCode`、`productName`、`categoryId`、`productImageUrl`、`state`、`locationIds`)。`id` 为空或仅空白的项**忽略**。整单规则与 Location / Team Member 批量编辑相同(`MaxBulkUpdateItems`、至少一条有效 `id` 等)。
  279 +
  280 +**返回** `ProductBulkUpdateResultDto`:`successCount`、`failCount`、`errors`(`rowNumber`、`id`、`message`)。
  281 +
  282 +---
  283 +
  284 +## 4 后续模块(预留)
  285 +
  286 +> 新模块的批量能力可在此追加 **## 7 xxx** 等章节,并更新文首 **目录** 与 **共享配置** 表(本节为占位,章节号可按实际顺延)。
  287 +
  288 +---
  289 +
  290 +## 5 Account Management(Company / Region)
  291 +
  292 +前端 **Account Management** 菜单中 **Company**、**Region** 两个页签的「Bulk Export (PDF)」对应后端已有接口:数据模型分别为 **`fl_partner`**(合作伙伴 / 公司)、**`fl_group`**(组织 / 大区);应用服务为 **`PartnerAppService`**、**`GroupAppService`**。导出为 **QuestPDF** 生成的 **PDF**,筛选条件与各自**分页列表**一致,**不使用** `SkipCount` / `MaxResultCount`(全量导出)。单次导出超过 **5000** 条时返回业务错误,需缩小筛选范围。
  293 +
  294 +### 5.1 Company(合作伙伴 / `PartnerAppService`)
  295 +
  296 +| 项目 | 说明 |
  297 +|------|------|
  298 +| 方法 | `ExportPdfAsync` |
  299 +| HTTP | `GET` |
  300 +| 常见路径 | `/api/app/partner/export-pdf` |
  301 +| Query | 与 Company 列表一致:`Keyword`、`State`、`Sorting` |
  302 +| 数据范围 | 符合筛选条件的**全部**记录(全量) |
  303 +| 排序 | 与列表 `GetListAsync` 内 `BuildPartnerListQuery` 一致(含 `Sorting` 各分支;无则 `CreationTime` 降序) |
  304 +| 响应 | `Content-Type: application/pdf`,文件名示例 `companies_yyyy-MM-dd_HH-mm-ss.pdf` |
  305 +| PDF 列 | Company(公司名称)、Contact(邮箱)、Phone、Status(active/inactive)、Created |
  306 +
  307 +### 5.2 Region(组织 / `GroupAppService`)
  308 +
  309 +| 项目 | 说明 |
  310 +|------|------|
  311 +| 方法 | `ExportPdfAsync` |
  312 +| HTTP | `GET` |
  313 +| 常见路径 | `/api/app/group/export-pdf` |
  314 +| Query | 与 Region 列表一致:`Keyword`、`PartnerId`(下拉「所属公司」对应 `fl_partner.Id`)、`State`、`Sorting` |
  315 +| 数据范围 | 符合筛选条件的**全部**记录(全量) |
  316 +| 排序 | 与列表 `GetListAsync` 内 `BuildGroupJoinedQuery` 一致 |
  317 +| 响应 | `Content-Type: application/pdf`,文件名示例 `regions_yyyy-MM-dd_HH-mm-ss.pdf` |
  318 +| PDF 列 | Region Name、Parent company、Status(active/inactive)、Created |
  319 +
  320 +**说明**:前端 `partnerService.exportPartnersPdf` / `groupService.exportGroupsPdf` 已按上述路径封装;鉴权与其它 `GET` 一致。
  321 +
  322 +---
  323 +
  324 +## 6 Reports — Print Log(Excel)
  325 +
  326 +**应用服务**:`ReportsAppService`(模块 `food-labeling-us`)。前端 **Reports** 菜单 **Print Log** 页签的「Export Report」在实现上调用 **Excel 全量导出**(与列表同一套筛选;**不使用**分页参数参与数据范围,仅可选用 `Sorting` 与列表对齐)。
  327 +
  328 +### 6.1 Print Log 批量导出 Excel
  329 +
  330 +| 项目 | 说明 |
  331 +|------|------|
  332 +| 方法 | `ExportPrintLogExcelAsync` |
  333 +| HTTP | `GET` |
  334 +| 常见路径 | `/api/app/reports/export-print-log-excel` |
  335 +| Query | 与 Print Log 分页列表一致:`PartnerId`、`GroupId`、`LocationId`、`StartDate`、`EndDate`、`Keyword`、`Sorting`(**不传** `SkipCount` / `MaxResultCount` 或传了也会被后端忽略;全量以筛选为准) |
  336 +| 数据范围 | 符合筛选条件的**全部**打印任务行(上限 **5000** 条;超出则 `UserFriendlyException`) |
  337 +| 排序 | 与列表一致:`Sorting` 为 `PrintedAt asc` 时按打印时间升序,否则按打印时间降序 |
  338 +| 响应 | `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,文件名示例 `print-log_yyyyMMdd-HHmmss.xlsx` |
  339 +| Excel 列(工作表名 `Print Log`) | Label ID、Product Name、Category、Template、Printed At、Printed By、Location、Expiry Date(空值语义与列表「无」一致) |
  340 +| 权限 | 与 `GetPrintLogListAsync` 相同:**admin** 可查全部;非 admin 仅导出本人打印记录 |
  341 +
  342 +**说明**:同模块另有 **`GET .../export-print-log-pdf`**(PDF);**Label Report** 页签仍使用 **`export-label-report-pdf`**。前端 `reportsService.exportPrintLogExcel` 已封装本接口。
  343 +
  344 +---
  345 +
  346 +## 附录 curl 模板
  347 +
  348 +将 `TOKEN` 替换为登录响应中的 `data.token` 整段;将 `BASE` 替换为实际基址(如 `http://localhost:19001`)。
  349 +
  350 +```bash
  351 +# 登录
  352 +curl -X POST "$BASE/api/oauth/Login" \
  353 + -H "Content-Type: application/x-www-form-urlencoded" \
  354 + -d "userName=admin&password=123456"
  355 +
  356 +# --- Location Manager ---
  357 +curl -X GET "$BASE/api/app/location/download-location-import-template" \
  358 + -H "Authorization: TOKEN" \
  359 + -o "Location-Manager-template.xlsx"
  360 +
  361 +curl -X GET "$BASE/api/app/location/export-locations-excel?Partner=&GroupName=&State=&Keyword=&Sorting=" \
  362 + -H "Authorization: TOKEN" \
  363 + -o "locations-export.xlsx"
  364 +
  365 +curl -X POST "$BASE/api/app/location/import-locations-batch" \
  366 + -H "Authorization: TOKEN" \
  367 + -F "file=@./Location-Manager-template.xlsx"
  368 +
  369 +curl -X POST "$BASE/api/app/location/update-locations-bulk" \
  370 + -H "Authorization: TOKEN" \
  371 + -H "Content-Type: application/json" \
  372 + -d "{\"items\":[{\"id\":\"YOUR_LOCATION_ID\",\"locationName\":\"UNCC store\",\"state\":true}]}"
  373 +
  374 +# --- Team Member ---
  375 +curl -X GET "$BASE/api/app/team-member/download-team-member-import-template" \
  376 + -H "Authorization: TOKEN" \
  377 + -o "Team-Member-template.xlsx"
  378 +
  379 +curl -X GET "$BASE/api/app/team-member/export-team-members-pdf?Keyword=&RoleId=&LocationId=&State=&Sorting=" \
  380 + -H "Authorization: TOKEN" \
  381 + -o "team-members.pdf"
  382 +
  383 +curl -X POST "$BASE/api/app/team-member/import-team-members-batch" \
  384 + -H "Authorization: TOKEN" \
  385 + -F "file=@./Team-Member-template.xlsx"
  386 +
  387 +curl -X POST "$BASE/api/app/team-member/update-team-members-bulk" \
  388 + -H "Authorization: TOKEN" \
  389 + -H "Content-Type: application/json" \
  390 + -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}]}"
  391 +
  392 +# --- Account Management:Company / Region(PDF 全量)---
  393 +curl -X GET "$BASE/api/app/partner/export-pdf?Keyword=&State=&Sorting=" \
  394 + -H "Authorization: TOKEN" \
  395 + -o "companies-export.pdf"
  396 +
  397 +curl -X GET "$BASE/api/app/group/export-pdf?Keyword=&PartnerId=&State=&Sorting=" \
  398 + -H "Authorization: TOKEN" \
  399 + -o "regions-export.pdf"
  400 +
  401 +# --- Reports:Print Log(Excel 全量)---
  402 +curl -X GET "$BASE/api/app/reports/export-print-log-excel?PartnerId=&GroupId=&LocationId=&StartDate=&EndDate=&Keyword=&Sorting=PrintedAt%20desc" \
  403 + -H "Authorization: TOKEN" \
  404 + -o "print-log-export.xlsx"
  405 +
  406 +# --- Products(菜单-产品)---
  407 +curl -X GET "$BASE/api/app/product/download-product-import-template" \
  408 + -H "Authorization: TOKEN" \
  409 + -o "Product-Manager-template.xlsx"
  410 +
  411 +curl -X GET "$BASE/api/app/product/export-products-excel?Keyword=&State=&Sorting=" \
  412 + -H "Authorization: TOKEN" \
  413 + -o "products-export.xlsx"
  414 +
  415 +curl -X POST "$BASE/api/app/product/import-products-batch" \
  416 + -H "Authorization: TOKEN" \
  417 + -F "file=@./Product-Manager-template.xlsx"
  418 +
  419 +curl -X POST "$BASE/api/app/product/update-products-bulk" \
  420 + -H "Authorization: TOKEN" \
  421 + -H "Content-Type: application/json" \
  422 + -d "{\"items\":[{\"id\":\"YOUR_PRODUCT_ID\",\"productName\":\"Tuna & Bacon Sub\",\"categoryId\":\"CATEGORY_ID\",\"productCode\":\"40001\",\"state\":true,\"locationIds\":[\"LOCATION_GUID_1\",\"LOCATION_GUID_2\"]}]}"
  423 +```
  424 +
  425 +更完整的接口测试流程见仓库内 `.codex/skills/api-interface-testing/SKILL.md`。
  426 +
  427 +---
  428 +
  429 +## 文档维护说明
  430 +
  431 +- **新增**某模块的导入/导出/下载模板/**批量编辑**接口时:在本文件 **目录** 表增加一行锚点,新增 **## n 模块名** 章节,并同步 **共享配置** 表(若有新配置项)。
  432 +- **避免**在多个 Markdown 中重复粘贴大段相同表格;门店分页与单条接口仍以 `门店(Location)接口对接说明.md` 为准;本文侧重「批量」与「多行一次提交」类接口。
... ...
项目相关文档/报表Reports接口对接说明.md
... ... @@ -8,8 +8,8 @@
8 8  
9 9 ## 0. 角色与数据范围(必读)
10 10  
11   -- 判断依据:`CurrentUser.Roles` 中是否存在**忽略大小写**等于 **`admin`** 的角色码(与 JWT 中角色码一致,参见 `AuthSessionAppService` / `ReportsRoleHelper`)。
12   -- **`admin`**:**不按** `CreatedBy` 过滤,可查看/统计全部 `fl_label_print_task`(仍受 Partner/Group/Location/日期/关键字筛选)。
  11 +- 判断依据(`ReportsRoleHelper.IsAdminRole`,满足其一即可):① `UserName` 为内置 **`admin`**;② JWT 自定义 claim **`Roles`** 中含 **`admin`**(内置超管走 `AccountManager.UserInfoToClaim`,不写标准 `role` claim);③ 任一 **`Permission`** claim 为 **`\*:\*:\*`**;④ 标准 **`CurrentUser.Roles`** 中含忽略大小写的 **`admin`**(普通账号绑定 `RoleCode=admin` 时走此路径)。
  12 +- **管理员(上述任一)**:**不按** `CreatedBy` 过滤,可查看/统计全部 `fl_label_print_task`(仍受 Partner/Group/Location/日期/关键字筛选)。
13 13 - **非 `admin`**:所有列表与统计仅包含 **`CreatedBy == 当前用户 Id`** 的打印任务。
14 14 - **重打**:非 admin 仅能重打本人任务;**`admin` 可重打任意用户任务**,但仍须 `locationId` 与历史任务一致(与 App 重打规则一致)。
15 15  
... ...
项目相关文档/标签模块接口对接说明.md
... ... @@ -582,7 +582,7 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
582 582  
583 583 方法:`POST /api/app/product`
584 584  
585   -入参(Body:`ProductCreateInputVo`)
  585 +入参(Body:`ProductCreateInputVo`)示例 1(自填编码)
586 586 ```json
587 587 {
588 588 "productCode": "PRD_TEST_001",
... ... @@ -597,10 +597,22 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
597 597 }
598 598 ```
599 599  
  600 +示例 2(不传或 `null` 产品编码时,由后端生成唯一值,形如 `PRD_` + 32 位十六进制):
  601 +```json
  602 +{
  603 + "productCode": null,
  604 + "productName": "Chicken",
  605 + "categoryId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",
  606 + "productImageUrl": null,
  607 + "state": true,
  608 + "locationIds": []
  609 +}
  610 +```
  611 +
600 612 字段说明:
601 613 | 字段 | 类型 | 必填 | 说明 |
602 614 |------|------|------|------|
603   -| `productCode` | string | 是 | 产品编码 |
  615 +| `productCode` | string \| null | **否** | **可选。** 有非空值时按该编码落库,且须全局唯一(未删除数据);不传、`null` 或空串时由后端 **自动生成**唯一 `productCode`。 |
604 616 | `productName` | string | 是 | 产品名称 |
605 617 | `categoryId` | string \| null | 否 | 产品分类 Id(`fl_product_category.id`) |
606 618 | `productImageUrl` | string \| null | 否 | 主图 URL |
... ... @@ -608,8 +620,8 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
608 620 | **`locationIds`** | `string[]` \| **省略** | 否 | **可选。** 有该字段时:在同一事务内按列表批量写入 **`fl_location_product`**(**每个门店 Id 一行**,即「一产品一门店一条关联」)。**请求体中省略该字段**时:本接口不写门店关联,仍可通过 **§7 Product-Location** 维护。传空数组 `[]` 表示新建产品后不绑定任何门店。 |
609 621  
610 622 校验:
611   -- `productCode` / `productName` 不能为空
612   -- `productCode` 不能与未删除的数据重复
  623 +- `productName` 不能为空
  624 +- 若请求中 **`productCode` 有非空值**:不能与**其它**未删除产品的 `productCode` 重复
613 625 - 若传入 **`locationIds`** 且含非空项:每个 Id 须为合法 Guid,且对应门店存在于 **`Location`** 主数据且未删除;否则返回友好错误(如「门店Id格式不正确」「门店不存在」)
614 626  
615 627 ### 6.4 编辑产品
... ... @@ -620,6 +632,9 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
620 632 - Path:`id` 为当前产品Id(`fl_product.Id`)
621 633 - Body:字段同新增(`ProductUpdateInputVo`,继承 `ProductCreateInputVo`)
622 634  
  635 +**`productCode` 行为:**
  636 +- 不传、`null` 或空串:**保留**原产品的 `productCode`;若原数据异常为空,则按新增规则自动生成唯一编码。
  637 +
623 638 **`locationIds` 行为(与新增不同,请注意):**
624 639 - **请求体中省略 `locationIds` 属性**:不修改 **`fl_location_product`**(仅更新 `fl_product` 主表字段;兼容原「先 PUT 产品再调 §7 同步门店」的调用方式)。
625 640 - **请求体中包含 `locationIds` 属性**(含空数组 `[]`):对该产品的门店关联做 **整表替换**——先删除本产品下全部 **`fl_location_product`** 行,再按列表逐条插入;`[]` 表示解除该产品与所有门店的关联。
... ... @@ -734,7 +749,8 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
734 749 - **门店内商品**:`fl_location_product`(先有 `locationId` 下的 `productId` 集合)。
735 750 - **参与连接的表**:`fl_label_product`(标签-产品)、`fl_label`(`LocationId` 须为当前门店且未删除、启用)、`fl_product`、`fl_label_category`、`fl_label_type`、`fl_label_template`。
736 751 - **第二级「产品分类」**:来自 `fl_product.CategoryName`,trim 后为空则归并为显示名 **`无`**。
737   -- **第四级去重**:同一产品在同一标签分类、同一门店下,多条 `fl_label` 若 **`labelCode` 相同**,只保留一条用于列表(预览/打印仍用返回的 `labelCode` 等业务字段)。
  752 +- **第三级拆卡**:同一 `productId` 若同时存在多套 **`fl_label.TemplateId`**(不同标签模板),在 **`products`** 中拆成 **多条 L3**(`productId` 可相同,以 **`templateId`** 区分);`itemCount` 为卡片条数。
  753 +- **第四级去重**:在同一 L3 卡片(同一产品、同一模板)下,多条 `fl_label` 若 **`labelCode` 相同**,只保留一条用于列表(预览/打印仍用返回的 `labelCode` 等业务字段)。
738 754  
739 755 #### 出参(`List<UsAppLabelCategoryTreeNodeDto>`)
740 756  
... ... @@ -762,14 +778,17 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
762 778 | `buttonAppearance` | string | **JSON 格式字符串**(与库中一致;空时默认 `"TEXT"`) |
763 779 | `availabilityType` | string | `ALL` / `SPECIFIED`(树已按当前门店过滤,仍返回供客户端展示) |
764 780 | `orderNum` | number | 排序 |
765   -| `itemCount` | number | 该分类下 **产品个数**(去重后的产品数) |
766   -| `products` | array | 第三级产品列表(见下表) |
  781 +| `itemCount` | number | 该分类下 **L3 产品卡片条数**(同一产品多模板会多张卡) |
  782 +| `products` | array | 第三级产品卡片列表(见下表) |
767 783  
768   -**L3 `UsAppLabelingProductNodeDto`(产品)**
  784 +**L3 `UsAppLabelingProductNodeDto`(产品卡片,按模板拆分)**
769 785  
770 786 | 字段 | 类型 | 说明 |
771 787 |------|------|------|
772 788 | `productId` | string | `fl_product.Id` |
  789 +| `templateId` | string | 当前卡片对应 **`fl_label.TemplateId`**;与 `productId` 组合唯一标识一张卡 |
  790 +| `templateCode` | string \| null | 当前卡片所用模板编码 |
  791 +| `templateLabelSizeText` | string \| null | 当前卡片模板尺寸文案(与四级中该模板尺寸一致) |
773 792 | `productName` | string | 产品名称 |
774 793 | `productCode` | string | 产品编码 |
775 794 | `productImageUrl` | string \| null | 主图 |
... ...
项目相关文档/门店(Location)接口对接说明.md
... ... @@ -7,6 +7,8 @@
7 7 门店模块的服务类为 `LocationAppService`(模块:`food-labeling-us`)。
8 8  
9 9 > 说明:接口的最终 URL 以 Swagger 展示为准(在 Swagger 里搜索 `Location` 或 `LocationAppService` 即可)。
  10 +>
  11 +> **批量**(下载模板 / Excel 导出 / Excel 导入 / **JSON 批量编辑**)与后续其它模块同类接口的**统一汇总文档**见:`批量导入导出接口说明.md`(本文件「接口 3~5」与汇总文档中导入导出一致;**批量编辑**见汇总文档 **1.4**;后续新增批量类接口建议优先更新汇总文档)。
10 12  
11 13 ---
12 14  
... ... @@ -31,7 +33,7 @@
31 33  
32 34 ### 方法签名
33 35  
34   -`Task<PagedResultDto<LocationGetListOutputDto>> GetListAsync(LocationGetListInputVo input)`
  36 +`Task<PagedResultWithPageDto<LocationGetListOutputDto>> GetListAsync(LocationGetListInputVo input)`
35 37  
36 38 ### 入参(LocationGetListInputVo)
37 39  
... ... @@ -43,9 +45,12 @@
43 45 - `GroupName`:Group 精确过滤(可选)
44 46 - `State`:启用状态过滤(可选,true/false)
45 47  
46   -### 出参(PagedResultDto<LocationGetListOutputDto>)
  48 +### 出参(PagedResultWithPageDto<LocationGetListOutputDto>)
47 49  
48   -- `TotalCount`:总数
  50 +- `PageIndex`:当前页码(从 1 开始)
  51 +- `PageSize`:每页条数
  52 +- `TotalCount`:总条数
  53 +- `TotalPages`:总页数
49 54 - `Items`:列表
50 55  
51 56 `LocationGetListOutputDto` 字段:
... ... @@ -92,10 +97,151 @@
92 97  
93 98 ---
94 99  
  100 +## 接口 3:下载批量导入模板(Excel 文件)
  101 +
  102 +从服务器配置的目录读取已部署的模板文件(如宝塔目录 `batchImportOfFiles` 下的 `Location-Manager-批量导入模板.xlsx`),以附件形式返回给浏览器/客户端。
  103 +
  104 +### 方法签名
  105 +
  106 +`Task<IActionResult> DownloadLocationImportTemplateAsync()`
  107 +
  108 +### HTTP
  109 +
  110 +- **方法**:`GET`
  111 +- **鉴权**:与其它 `LocationAppService` 接口一致(`Authorization` 携带登录返回的 `data.token` 完整值)
  112 +
  113 +### 常见路由(以 Swagger 为准)
  114 +
  115 +在 `RootPath = api/app`、约定式控制器默认命名规则下,一般为:
  116 +
  117 +- `GET /api/app/location/download-location-import-template`
  118 +
  119 +### 响应
  120 +
  121 +- **成功**:`Content-Type` 为 `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,`Content-Disposition` 带下载文件名(与服务器上模板文件名一致)
  122 +- **失败**:业务异常提示(如未配置目录、模板文件不存在)
  123 +
  124 +### 相关配置(`appsettings`)
  125 +
  126 +配置节:`FoodLabeling:BatchImport`(类名 `FoodLabelingBatchImportOptions`)
  127 +
  128 +| 配置项 | 说明 |
  129 +|--------|------|
  130 +| `TemplateDirectory` | 模板所在目录绝对路径(生产示例:`/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles`) |
  131 +| `LocationTemplateFileName` | Location 模板文件名(默认:`Location-Manager-批量导入模板.xlsx`) |
  132 +
  133 +---
  134 +
  135 +## 接口 4:批量导出门店(Excel)
  136 +
  137 +按与 **接口 1** 相同的筛选条件**全量**导出门店数据;导出为 `.xlsx`,列顺序与 Location Manager 表头及导入模板一致(含 `Latitude`、`Longitude`、`Active` 等)。**不按条数截断**;数据量大时占用内存与生成时间会增加。
  138 +
  139 +### 方法签名
  140 +
  141 +`Task<IActionResult> ExportLocationsExcelAsync(LocationGetListInputVo input)`
  142 +
  143 +### HTTP
  144 +
  145 +- **方法**:`GET`
  146 +- **Query**:筛选条件与接口 1 一致:`Sorting`、`Keyword`、`Partner`、`GroupName`、`State`。导出为**全量**(符合筛选的全部记录),**不使用**请求里的 `SkipCount` / `MaxResultCount`。
  147 +
  148 +### 常见路由(以 Swagger 为准)
  149 +
  150 +- `GET /api/app/location/export-locations-excel?Partner=...&GroupName=...&State=...&Keyword=...&Sorting=...`
  151 +
  152 +### 响应
  153 +
  154 +- **成功**:`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,文件名形如 `locations-export-yyyyMMdd-HHmmss.xlsx`
  155 +- **数据范围**:在筛选结果上按 `Sorting` 或默认 `CreationTime` 降序导出**全部**行(无条数上限)
  156 +
  157 +### 相关配置
  158 +
  159 +门店导出**不再**使用条数上限配置;与导出体积相关的仅内存与 Excel 生成耗时。模板目录等仍见上文「相关配置(`appsettings`)」中 `FoodLabeling:BatchImport` 其它项。
  160 +
  161 +---
  162 +
  163 +## 接口 5:批量导入门店(Excel)
  164 +
  165 +上传 `.xlsx`,按行解析后逐行调用与 **接口 2** 相同的新增逻辑;单行失败不影响其它行处理,最终在 JSON 中返回成功数、失败数及错误明细。
  166 +
  167 +### 方法签名
  168 +
  169 +`Task<LocationBatchImportResultDto> ImportLocationsBatchAsync(LocationBatchImportInputVo input)`
  170 +
  171 +### HTTP
  172 +
  173 +- **方法**:`POST`
  174 +- **Content-Type**:`multipart/form-data`
  175 +- **表单字段**:`file`(类型:文件,扩展名必须为 `.xlsx`)
  176 +
  177 +### 常见路由(以 Swagger 为准)
  178 +
  179 +- `POST /api/app/location/import-locations-batch`
  180 +
  181 +### 入参(表单)
  182 +
  183 +- `file`:Excel 文件(必填)
  184 +
  185 +### 出参(LocationBatchImportResultDto)
  186 +
  187 +- `SuccessCount`:成功新增条数
  188 +- `FailCount`:失败条数(含解析阶段与逐行 `CreateAsync` 业务校验失败)
  189 +- `SkippedEmptyRows`:预留字段,当前实现一般为 `0`
  190 +- `Errors`:错误列表(`LocationBatchImportErrorDto`)
  191 + - `RowNumber`:Excel 行号(表头为第 1 行,数据从第 2 行起;解析类错误可能为 `0`)
  192 + - `LocationCode`:该行 Location ID(若有)
  193 + - `Message`:错误说明
  194 +
  195 +### 解析与业务规则摘要
  196 +
  197 +- 表头需能识别 **Location ID** 列(或同义列,如 `LocationCode`);建议使用服务器提供的官方模板。
  198 +- **必填**:`Location ID`(`LocationCode`)、`Location Name`(与单条新增接口一致)。
  199 +- **可选**:Company/Region、地址、电话、邮箱、经纬度、`Active` 等;经纬度有值时校验格式,空则按 `null` 入库。
  200 +- 单次处理行数上限:`MaxImportRows`(默认 5000);单文件大小上限:`MaxUploadBytes`(默认 10MB)。
  201 +
  202 +### 相关配置
  203 +
  204 +| 配置项 | 说明 |
  205 +|--------|------|
  206 +| `MaxImportRows` | 单次导入最多数据行数 |
  207 +| `MaxUploadBytes` | 上传文件最大字节数 |
  208 +
  209 +---
  210 +
  211 +## curl 示例(本地 `http://localhost:19001`)
  212 +
  213 +请先按项目规范登录获取 `data.token`(见 `项目相关文档` 或 `.codex/skills/api-interface-testing`),以下用环境变量占位:
  214 +
  215 +```bash
  216 +# 登录(示例账号以环境为准)
  217 +curl -X POST "http://localhost:19001/api/oauth/Login" \
  218 + -H "Content-Type: application/x-www-form-urlencoded" \
  219 + -d "userName=admin&password=123456"
  220 +
  221 +# 下载模板(将 TOKEN 替换为响应中的 data.token 整段)
  222 +curl -X GET "http://localhost:19001/api/app/location/download-location-import-template" \
  223 + -H "Authorization: TOKEN" \
  224 + -o "Location-Manager-template.xlsx"
  225 +
  226 +# 导出(带筛选示例,可按需删参)
  227 +curl -X GET "http://localhost:19001/api/app/location/export-locations-excel?Partner=MedVantage%20Cafe%20Group&Keyword=" \
  228 + -H "Authorization: TOKEN" \
  229 + -o "locations-export.xlsx"
  230 +
  231 +# 批量导入(字段名必须为 file)
  232 +curl -X POST "http://localhost:19001/api/app/location/import-locations-batch" \
  233 + -H "Authorization: TOKEN" \
  234 + -F "file=@./Location-Manager-template.xlsx"
  235 +```
  236 +
  237 +若实际路径与上表不一致,**以 Swagger「食品标签-美国版接口」中 `LocationAppService` 展示的路径为准**。
  238 +
  239 +---
  240 +
95 241 ## Swagger 中如何找到
96 242  
97 243 1. 启动后端宿主(`Yi.Abp.Web`),确保端口为 `19001`
98 244 2. 打开 `http://localhost:19001/swagger`
99   -3. 在接口分组里找到 **“食品标签-美国版接口”** 或直接搜索 `Location`
100   -4. 查看 `LocationAppService` 的 `GetListAsync` 与 `CreateAsync`
  245 +3. 在接口分组里找到 **「食品标签-美国版接口」** 或直接搜索 `Location`
  246 +4. 查看 `LocationAppService`:`GetListAsync`、`CreateAsync`、`UpdateAsync`、`DeleteAsync`,以及 **`DownloadLocationImportTemplateAsync`、`ExportLocationsExcelAsync`、`ImportLocationsBatchAsync`**
101 247  
... ...