Commit 628891b0f08bf392891a02a47f0974c80ddd7da0
合并
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
项目相关文档/批量导入导出接口说明.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 | ... | ... |