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