using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using FoodLabeling.Application.Contracts.Dtos.Common; using FoodLabeling.Application.Contracts.Dtos.Label; using FoodLabeling.Application.Contracts.Dtos.LabelTemplate; using FoodLabeling.Application.Contracts.Dtos.Reports; using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Helpers; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Domain.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.Guids; using Volo.Abp.Uow; using Yi.Framework.Rbac.Domain.Entities; using Yi.Framework.SqlSugarCore.Abstractions; namespace FoodLabeling.Application.Services; /// /// App Labeling:四级列表(标签分类 → 产品分类 → 产品 → 标签种类) /// public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppService { private readonly ISqlSugarDbContext _dbContext; private readonly ILabelAppService _labelAppService; private readonly IGuidGenerator _guidGenerator; private readonly ISqlSugarRepository _userRepository; public UsAppLabelingAppService( ISqlSugarDbContext dbContext, ILabelAppService labelAppService, IGuidGenerator guidGenerator, ISqlSugarRepository userRepository) { _dbContext = dbContext; _labelAppService = labelAppService; _guidGenerator = guidGenerator; _userRepository = userRepository; } /// /// 获取当前门店下四级嵌套数据 /// /// /// L1 标签分类 fl_label_category(含 buttonAppearance;COLOR/IMAGE 展示值在 categoryPhotoUrl);仅对当前门店可用:ALL 或 SPECIFIED 且在 fl_label_category_location; /// L2 产品分类 fl_product.CategoryId join fl_product_category(同上,展示值在 categoryPhotoUrl); /// L3 产品卡片:按「产品 + 标签模板」拆分(同一 productId、不同 fl_label.TemplateId 为多张卡);L4 为该卡下与门店、标签分类、该产品、该模板关联的标签实例(fl_label + fl_label_type)。 /// L2 产品分类展示名来自 fl_product_category;产品范围已由 fl_location_product 限定当前门店, /// 不再因产品分类 SPECIFIED 未配 fl_product_category_location 而整行过滤(避免 App 全店空数据)。 /// 未归类或分类行未关联到 fl_product_category 时仍归入「无」节点。 /// [Authorize] public virtual async Task> GetLabelingTreeAsync(UsAppLabelingTreeInputVo input) { if (string.IsNullOrWhiteSpace(input.LocationId)) { throw new UserFriendlyException("门店Id不能为空"); } var locationId = input.LocationId.Trim(); var keyword = input.Keyword?.Trim(); var filterCategoryId = input.LabelCategoryId?.Trim(); await UsAppPrintLogScopeHelper.EnsureUserBoundToLocationAsync( CurrentUser, _dbContext.SqlSugarClient, locationId); var productIds = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.LocationId == locationId) .Select(x => x.ProductId) .ToListAsync(); if (productIds.Count == 0) { return new List(); } var query = BuildLabelingJoinQuery(locationId, productIds, filterCategoryId, keyword); var raw = await query .Select((lp, l, p, c, t, tpl, pc) => new LabelingTreeRow { LabelCategoryId = c.Id, LabelCategoryName = c.CategoryName, LabelCategoryPhotoUrl = c.CategoryPhotoUrl, LabelCategoryButtonAppearance = c.ButtonAppearance, LabelCategoryOrderNum = c.OrderNum, ProductCategoryId = p.CategoryId, ProductCategoryName = pc.CategoryName, ProductCategoryPhotoUrl = pc.CategoryPhotoUrl, ProductCategoryDisplayText = pc.DisplayText, ProductCategoryButtonAppearance = pc.ButtonAppearance, ProductCategoryAvailabilityType = pc.AvailabilityType, ProductCategoryOrderNum = pc.OrderNum, ProductId = p.Id, ProductName = p.ProductName, ProductCode = p.ProductCode, ProductImageUrl = p.ProductImageUrl, LabelTypeId = t.Id, TypeName = t.TypeName, TypeOrderNum = t.OrderNum, LabelCode = l.LabelCode ?? string.Empty, TemplateId = tpl.Id, TemplateCode = tpl.TemplateCode, TemplateWidth = tpl.Width, TemplateHeight = tpl.Height, TemplateUnit = tpl.Unit }) .ToListAsync(); if (raw.Count == 0) { return new List(); } var byL1 = raw.GroupBy(x => new { x.LabelCategoryId, x.LabelCategoryName, x.LabelCategoryPhotoUrl, x.LabelCategoryButtonAppearance, x.LabelCategoryOrderNum }).OrderBy(g => g.Key.LabelCategoryOrderNum).ThenBy(g => g.Key.LabelCategoryName); var result = new List(); foreach (var g1 in byL1) { var l1Appearance = string.IsNullOrWhiteSpace(g1.Key.LabelCategoryButtonAppearance) ? "TEXT" : g1.Key.LabelCategoryButtonAppearance.Trim(); var l1 = new UsAppLabelCategoryTreeNodeDto { Id = g1.Key.LabelCategoryId, CategoryName = g1.Key.LabelCategoryName ?? string.Empty, CategoryPhotoUrl = g1.Key.LabelCategoryPhotoUrl, ButtonAppearance = l1Appearance, OrderNum = g1.Key.LabelCategoryOrderNum, ProductCategories = new List() }; var byL2 = g1.GroupBy(x => { var categoryId = NormalizeNullableId(x.ProductCategoryId); if (categoryId is null) { return new { CategoryId = (string?)null, CategoryName = "无", CategoryPhotoUrl = (string?)null, DisplayText = (string?)null, ButtonAppearance = (string?)null, AvailabilityType = (string?)null, CategoryOrderNum = int.MaxValue }; } var categoryName = NormalizeCategoryName(x.ProductCategoryName); var categoryPhotoUrl = NormalizeNullableUrl(x.ProductCategoryPhotoUrl); return new { CategoryId = (string?)categoryId, CategoryName = categoryName, CategoryPhotoUrl = categoryPhotoUrl, DisplayText = NormalizeNullableUrl(x.ProductCategoryDisplayText), ButtonAppearance = NormalizeNullableId(x.ProductCategoryButtonAppearance), AvailabilityType = NormalizeNullableId(x.ProductCategoryAvailabilityType), CategoryOrderNum = x.ProductCategoryOrderNum }; }) .OrderBy(g => g.Key.CategoryOrderNum) .ThenBy(g => g.Key.CategoryName); foreach (var g2 in byL2) { 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(); var availability = string.IsNullOrWhiteSpace(g2.Key.AvailabilityType) ? "ALL" : g2.Key.AvailabilityType.Trim().ToUpperInvariant(); var l2 = new UsAppProductCategoryNodeDto { CategoryId = g2.Key.CategoryId, CategoryPhotoUrl = g2.Key.CategoryPhotoUrl, Name = g2.Key.CategoryName, DisplayText = g2.Key.DisplayText, ButtonAppearance = appearance, AvailabilityType = availability, OrderNum = g2.Key.CategoryOrderNum == int.MaxValue ? 0 : g2.Key.CategoryOrderNum, ItemCount = productsGrouped.Count(), Products = new List() }; foreach (var g3 in productsGrouped) { var first = g3.First(); var typeNodes = g3 .GroupBy(r => r.LabelCode) .Select(gr => BuildLabelTypeNode(gr.First())) .OrderBy(t => t.OrderNum) .ThenBy(t => t.TypeName) .ToList(); 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, Subtitle = subtitle, LabelTypeCount = typeNodes.Count, LabelTypes = typeNodes }); } l1.ProductCategories.Add(l2); } result.Add(l1); } return result; } /// /// App 打印预览:按标签编码解析模板并返回顶部展示字段 + 预览模板结构 /// /// /// 示例请求: /// ```json /// { /// "locationId": "LOC001", /// "labelCode": "LBL0001", /// "productId": "PROD001", /// "baseTime": "2026-03-26T10:30:00", /// "printInputJson": { /// "price": "12.99" /// } /// } /// ``` /// /// 预览入参 /// 顶部字段 + 预览模板结构 /// 成功 /// 参数错误/数据不存在 /// 服务器错误 [Authorize] public virtual async Task PreviewAsync(UsAppLabelPreviewInputVo input) { if (input is null) { throw new UserFriendlyException("入参不能为空"); } var locationId = input.LocationId?.Trim(); if (string.IsNullOrWhiteSpace(locationId)) { throw new UserFriendlyException("门店Id不能为空"); } var labelCode = input.LabelCode?.Trim(); if (string.IsNullOrWhiteSpace(labelCode)) { throw new UserFriendlyException("labelCode不能为空"); } var labelRow = await _dbContext.SqlSugarClient .Queryable( (l, c, t, tpl) => l.LabelCategoryId == c.Id && l.LabelTypeId == t.Id && l.TemplateId == tpl.Id) .Where((l, c, t, tpl) => !l.IsDeleted && l.State) .Where((l, c, t, tpl) => !c.IsDeleted && c.State) .Where((l, c, t, tpl) => !t.IsDeleted && t.State) .Where((l, c, t, tpl) => !tpl.IsDeleted) .Where((l, c, t, tpl) => l.LabelCode == labelCode) .Select((l, c, t, tpl) => new { l.Id, l.LabelCode, l.LocationId, l.LabelTypeId, l.TemplateId, l.LastModificationTime, l.CreationTime, LabelCategoryName = c.CategoryName, TypeName = t.TypeName, TemplateCode = tpl.TemplateCode, TemplateWidth = tpl.Width, TemplateHeight = tpl.Height, TemplateUnit = tpl.Unit }) .FirstAsync(); if (labelRow is null) { throw new UserFriendlyException("标签不存在或不可用"); } if (!string.Equals(labelRow.LocationId?.Trim(), locationId, StringComparison.OrdinalIgnoreCase)) { throw new UserFriendlyException("该标签不属于当前门店"); } var previewProductId = await ResolvePreviewProductIdAsync(labelRow.Id, input.ProductId); var template = await _labelAppService.PreviewAsync(new LabelPreviewResolveInputVo { LabelCode = labelCode, ProductId = previewProductId, BaseTime = input.BaseTime, PrintInputJson = input.PrintInputJson?.ToDictionary(x => x.Key, x => (object?)x.Value) }); Dictionary? templateProductDefaultValues = null; if (!string.IsNullOrWhiteSpace(previewProductId)) { var productDefault = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.TemplateId == labelRow.TemplateId) .Where(x => x.ProductId == previewProductId) .Where(x => x.LabelTypeId == labelRow.LabelTypeId) .OrderBy(x => x.OrderNum) .FirstAsync(); if (!string.IsNullOrWhiteSpace(productDefault?.DefaultValuesJson)) { try { templateProductDefaultValues = JsonSerializer.Deserialize>(productDefault.DefaultValuesJson!); } catch { templateProductDefaultValues = null; } } } var productName = string.Empty; var productCategoryName = "无"; if (!string.IsNullOrWhiteSpace(previewProductId)) { var p = await _dbContext.SqlSugarClient.Queryable() .FirstAsync(x => !x.IsDeleted && x.State && x.Id == previewProductId); if (p is not null) { productName = p.ProductName ?? string.Empty; if (!string.IsNullOrWhiteSpace(p.CategoryId)) { var pc = await _dbContext.SqlSugarClient.Queryable() .FirstAsync(x => !x.IsDeleted && x.State && x.Id == p.CategoryId); productCategoryName = NormalizeCategoryName(pc?.CategoryName); } } } return new UsAppLabelPreviewDto { LabelId = labelRow.Id, LocationId = locationId, LabelCode = labelCode, TemplateCode = labelRow.TemplateCode, LabelSizeText = FormatLabelSize(labelRow.TemplateWidth, labelRow.TemplateHeight, labelRow.TemplateUnit), TypeName = labelRow.TypeName, ProductName = string.IsNullOrWhiteSpace(productName) ? null : productName, ProductCategoryName = productCategoryName, LabelCategoryName = labelRow.LabelCategoryName, LabelLastEdited = labelRow.LastModificationTime ?? labelRow.CreationTime, PreviewImageBase64Png = null, Template = template, TemplateProductDefaultValues = templateProductDefaultValues }; } /// /// App 打印:创建打印任务并落库打印明细(fl_label_print_task / fl_label_print_data) /// /// 打印入参 /// 任务Id [Authorize] [UnitOfWork] public virtual async Task PrintAsync(UsAppLabelPrintInputVo input) { if (input is null) { throw new UserFriendlyException("入参不能为空"); } var locationId = input.LocationId?.Trim(); if (string.IsNullOrWhiteSpace(locationId)) { throw new UserFriendlyException("门店Id不能为空"); } var labelCode = input.LabelCode?.Trim(); if (string.IsNullOrWhiteSpace(labelCode)) { throw new UserFriendlyException("labelCode不能为空"); } var quantity = input.PrintQuantity <= 0 ? 1 : input.PrintQuantity; var clientRequestId = input.ClientRequestId?.Trim(); if (!string.IsNullOrWhiteSpace(clientRequestId)) { // 幂等:同一个 clientRequestId 重复调用,直接返回首次创建的任务集合 var existed = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.ClientRequestId == clientRequestId) .OrderBy(x => x.CopyIndex) .ToListAsync(); if (existed is not null && existed.Count > 0) { var existedBatchId = existed.First().BatchId; var existedTaskIds = existed.Select(x => x.Id).ToList(); return new UsAppLabelPrintOutputDto { TaskId = existedTaskIds.FirstOrDefault() ?? string.Empty, PrintQuantity = existedTaskIds.Count, BatchId = existedBatchId, TaskIds = existedTaskIds }; } } // 校验 label + location,并补齐一些顶部字段用于任务表落库 var labelRow = await _dbContext.SqlSugarClient .Queryable( (l, t, tpl) => l.LabelTypeId == t.Id && l.TemplateId == tpl.Id) .Where((l, t, tpl) => !l.IsDeleted && l.State) .Where((l, t, tpl) => !t.IsDeleted && t.State) .Where((l, t, tpl) => !tpl.IsDeleted) .Where((l, t, tpl) => l.LabelCode == labelCode) .Select((l, t, tpl) => new { l.Id, l.LocationId, l.LabelTypeId, l.TemplateId }) .FirstAsync(); if (labelRow is null) { throw new UserFriendlyException("标签不存在或不可用"); } if (!string.Equals(labelRow.LocationId?.Trim(), locationId, StringComparison.OrdinalIgnoreCase)) { throw new UserFriendlyException("该标签不属于当前门店"); } var previewProductId = await ResolvePreviewProductIdAsync(labelRow.Id, input.ProductId); var normalizedPrintInput = ParsePrintInputJsonToDictionary(input.PrintInputJson); // 解析模板 elements(与预览一致的渲染数据) var resolvedTemplate = await _labelAppService.PreviewAsync(new LabelPreviewResolveInputVo { LabelCode = labelCode, ProductId = previewProductId, BaseTime = input.BaseTime, PrintInputJson = normalizedPrintInput }); var templateProductDefaultValuesJson = await ResolveTemplateProductDefaultValuesJsonAsync( labelRow.TemplateId, previewProductId, labelRow.LabelTypeId); var printInputJsonStr = input.PrintInputJson is null ? null : JsonSerializer.Serialize(input.PrintInputJson); var renderTemplateJsonStr = JsonSerializer.Serialize(resolvedTemplate); var now = DateTime.Now; var currentUserId = CurrentUser?.Id?.ToString(); var batchId = _guidGenerator.Create().ToString(); var taskIds = new List(); for (var i = 1; i <= quantity; i++) { var taskId = _guidGenerator.Create().ToString(); taskIds.Add(taskId); var task = new FlLabelPrintTaskDbEntity { Id = taskId, BatchId = batchId, CopyIndex = i, ClientRequestId = string.IsNullOrWhiteSpace(clientRequestId) ? null : clientRequestId, LabelId = labelRow.Id, TemplateId = labelRow.TemplateId, LabelTypeId = labelRow.LabelTypeId, ProductId = previewProductId, LocationId = locationId, BaseTime = input.BaseTime, PrintInputJson = printInputJsonStr, TemplateProductDefaultValuesJson = templateProductDefaultValuesJson, RenderTemplateJson = renderTemplateJsonStr, PrinterId = input.PrinterId?.Trim(), PrinterMac = input.PrinterMac?.Trim(), PrinterAddress = input.PrinterAddress?.Trim(), Status = "CREATED", PrintedAt = null, ErrorMessage = null, CreatedBy = currentUserId, CreationTime = now }; await _dbContext.SqlSugarClient.Insertable(task).ExecuteCommandAsync(); var rows = resolvedTemplate.Elements.Select(e => { var cfgJson = e.ConfigJson is null ? null : JsonSerializer.Serialize(e.ConfigJson); string? renderValue = null; if (e.ConfigJson is JsonElement je && je.ValueKind == JsonValueKind.Object && je.TryGetProperty("text", out var tv)) { renderValue = tv.ValueKind == JsonValueKind.String ? tv.GetString() : tv.ToString(); } else if (e.ConfigJson is Dictionary dict && dict.TryGetValue("text", out var v)) { renderValue = v?.ToString(); } return new FlLabelPrintDataDbEntity { Id = _guidGenerator.Create().ToString(), PrintTaskId = taskId, ElementId = e.Id?.Trim() ?? string.Empty, ElementName = e.ElementName?.Trim(), RenderValue = renderValue, RenderConfigJson = cfgJson }; }).Where(x => !string.IsNullOrWhiteSpace(x.ElementId)).ToList(); if (rows.Count > 0) { await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync(); } } return new UsAppLabelPrintOutputDto { TaskId = taskIds.FirstOrDefault() ?? string.Empty, PrintQuantity = quantity, BatchId = batchId, TaskIds = taskIds }; } /// /// App 重新打印:根据历史任务Id重打(创建新任务与明细) /// [Authorize] [UnitOfWork] public virtual async Task ReprintAsync(UsAppLabelReprintInputVo input) { if (input is null) { throw new UserFriendlyException("入参不能为空"); } var locationId = input.LocationId?.Trim(); if (string.IsNullOrWhiteSpace(locationId)) { throw new UserFriendlyException("门店Id不能为空"); } var taskId = input.TaskId?.Trim(); if (string.IsNullOrWhiteSpace(taskId)) { throw new UserFriendlyException("taskId不能为空"); } var quantity = input.PrintQuantity <= 0 ? 1 : input.PrintQuantity; var clientRequestId = input.ClientRequestId?.Trim(); if (!string.IsNullOrWhiteSpace(clientRequestId)) { var existed = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.ClientRequestId == clientRequestId) .OrderBy(x => x.CopyIndex) .ToListAsync(); if (existed is not null && existed.Count > 0) { var existedBatchId = existed.First().BatchId; var existedTaskIds = existed.Select(x => x.Id).ToList(); return new UsAppLabelPrintOutputDto { TaskId = existedTaskIds.FirstOrDefault() ?? string.Empty, PrintQuantity = existedTaskIds.Count, BatchId = existedBatchId, TaskIds = existedTaskIds }; } } var currentUserId = CurrentUser?.Id?.ToString(); if (string.IsNullOrWhiteSpace(currentUserId)) { throw new UserFriendlyException("未登录"); } var old = await _dbContext.SqlSugarClient.Queryable() .FirstAsync(x => x.Id == taskId); if (old is null) { throw new UserFriendlyException("打印任务不存在"); } // 管理员 / Partner 角色:可重打当前门店任意用户任务;其它角色仅本人 var canViewAll = await UsAppPrintLogScopeHelper.CanViewAllPrintsAtLocationAsync( CurrentUser, _dbContext.SqlSugarClient); if (!canViewAll && !string.Equals(old.CreatedBy?.Trim(), currentUserId, StringComparison.OrdinalIgnoreCase)) { throw new UserFriendlyException("无权限重打该任务"); } if (!string.Equals(old.LocationId?.Trim(), locationId, StringComparison.OrdinalIgnoreCase)) { throw new UserFriendlyException("该任务不属于当前门店"); } LabelTemplatePreviewDto? resolvedTemplate = null; try { resolvedTemplate = JsonSerializer.Deserialize(old.RenderTemplateJson); } catch { resolvedTemplate = null; } if (resolvedTemplate is null) { throw new UserFriendlyException("历史任务渲染快照解析失败,无法重打"); } var now = DateTime.Now; var batchId = _guidGenerator.Create().ToString(); var taskIds = new List(); for (var i = 1; i <= quantity; i++) { var newTaskId = _guidGenerator.Create().ToString(); taskIds.Add(newTaskId); var newTask = new FlLabelPrintTaskDbEntity { Id = newTaskId, BatchId = batchId, CopyIndex = i, ClientRequestId = string.IsNullOrWhiteSpace(clientRequestId) ? null : clientRequestId, LabelId = old.LabelId, TemplateId = old.TemplateId, LabelTypeId = old.LabelTypeId, ProductId = old.ProductId, LocationId = old.LocationId, BaseTime = old.BaseTime, PrintInputJson = old.PrintInputJson, TemplateProductDefaultValuesJson = old.TemplateProductDefaultValuesJson, RenderTemplateJson = old.RenderTemplateJson, PrinterId = string.IsNullOrWhiteSpace(input.PrinterId) ? old.PrinterId : input.PrinterId.Trim(), PrinterMac = string.IsNullOrWhiteSpace(input.PrinterMac) ? old.PrinterMac : input.PrinterMac.Trim(), PrinterAddress = string.IsNullOrWhiteSpace(input.PrinterAddress) ? old.PrinterAddress : input.PrinterAddress.Trim(), Status = "CREATED", PrintedAt = null, ErrorMessage = null, CreatedBy = currentUserId, CreationTime = now }; await _dbContext.SqlSugarClient.Insertable(newTask).ExecuteCommandAsync(); var rows = resolvedTemplate.Elements.Select(e => { var cfgJson = e.ConfigJson is null ? null : JsonSerializer.Serialize(e.ConfigJson); string? renderValue = null; if (e.ConfigJson is JsonElement je && je.ValueKind == JsonValueKind.Object && je.TryGetProperty("text", out var tv)) { renderValue = tv.ValueKind == JsonValueKind.String ? tv.GetString() : tv.ToString(); } else if (e.ConfigJson is Dictionary dict && dict.TryGetValue("text", out var v)) { renderValue = v?.ToString(); } return new FlLabelPrintDataDbEntity { Id = _guidGenerator.Create().ToString(), PrintTaskId = newTaskId, ElementId = e.Id?.Trim() ?? string.Empty, ElementName = e.ElementName?.Trim(), RenderValue = renderValue, RenderConfigJson = cfgJson }; }).Where(x => !string.IsNullOrWhiteSpace(x.ElementId)).ToList(); if (rows.Count > 0) { await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync(); } } return new UsAppLabelPrintOutputDto { TaskId = taskIds.FirstOrDefault() ?? string.Empty, PrintQuantity = quantity, BatchId = batchId, TaskIds = taskIds }; } /// /// App 打印日志:当前门店打印记录(分页,时间倒序) /// /// /// 数据范围(须已绑定 input.locationId): /// /// 管理员)或角色码/名含 partner:该门店 全部 打印任务; /// 其它角色:仅 CreatedBy == CurrentUser.Id /// /// /// 示例请求: /// ```json /// { /// "locationId": "11111111-1111-1111-1111-111111111111", /// "skipCount": 1, /// "maxResultCount": 20 /// } /// ``` /// /// 参数说明: /// - locationId: 当前门店 Id(必填) /// - skipCount: 页码(从 1 开始,遵循本项目约定) /// - maxResultCount: 每页条数 /// /// 分页查询入参 /// 分页打印日志 /// 成功 /// 参数错误/未登录 /// 服务器错误 [Authorize] [HttpPost] public virtual async Task> GetPrintLogListAsync(PrintLogGetListInputVo input) { if (input is null) { throw new UserFriendlyException("入参不能为空"); } if (!CurrentUser.Id.HasValue) { throw new UserFriendlyException("用户未登录"); } var locationId = input.LocationId?.Trim(); if (string.IsNullOrWhiteSpace(locationId)) { throw new UserFriendlyException("门店Id不能为空"); } var currentUserIdStr = CurrentUser.Id.Value.ToString(); await UsAppPrintLogScopeHelper.EnsureUserBoundToLocationAsync( CurrentUser, _dbContext.SqlSugarClient, locationId); var canViewAll = await UsAppPrintLogScopeHelper.CanViewAllPrintsAtLocationAsync( CurrentUser, _dbContext.SqlSugarClient); var restrictToCreator = !canViewAll; var locationName = "无"; if (Guid.TryParse(locationId, out var locationGuid)) { var loc = await _dbContext.SqlSugarClient.Queryable() .Where(x => !x.IsDeleted && x.Id == locationGuid) .Select(x => new { x.LocationCode, x.LocationName }) .FirstAsync(); if (loc is not null) { var name = loc.LocationName?.Trim(); if (!string.IsNullOrWhiteSpace(name)) { locationName = name; } } } RefAsync total = 0; var query = UsAppPrintLogScopeHelper.BuildLocationPrintTaskQuery( _dbContext.SqlSugarClient, locationId, restrictToCreator, currentUserIdStr) .OrderBy((t, l, p, lt, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime), OrderByType.Desc) .OrderBy((t, l, p, lt, tpl) => t.CreationTime, OrderByType.Desc) .Select((t, l, p, lt, tpl) => new { t.Id, t.BatchId, t.CopyIndex, t.LabelId, LabelCode = l.LabelCode, t.ProductId, ProductName = p.ProductName, TypeName = lt.TypeName, TemplateWidth = tpl.Width, TemplateHeight = tpl.Height, TemplateUnit = tpl.Unit, t.PrintInputJson, t.PrintedAt, t.CreationTime, t.CreatedBy }); var pageRows = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total); var operatorMap = await UsAppPrintLogScopeHelper.LoadOperatorNameMapAsync( _dbContext.SqlSugarClient, pageRows.Select(x => x.CreatedBy)); var items = pageRows.Select(x => new PrintLogItemDto { TaskId = x.Id, BatchId = x.BatchId, CopyIndex = x.CopyIndex, LabelId = x.LabelId, LabelCode = x.LabelCode ?? string.Empty, ProductId = x.ProductId, ProductName = string.IsNullOrWhiteSpace(x.ProductName) ? "无" : x.ProductName.Trim(), TypeName = x.TypeName ?? string.Empty, LabelSizeText = FormatLabelSizeWithUnit(x.TemplateWidth, x.TemplateHeight, x.TemplateUnit), PrintInputJson = x.PrintInputJson, PrintedAt = x.PrintedAt ?? x.CreationTime, OperatorName = UsAppPrintLogScopeHelper.ResolveOperatorName(operatorMap, x.CreatedBy), LocationName = locationName }).ToList(); var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount; var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount); var totalCount = (long)total; var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize); return new PagedResultWithPageDto { PageIndex = pageIndex, PageSize = pageSize, TotalCount = totalCount, TotalPages = totalPages, Items = items }; } /// /// App Label Report:当前门店打印统计(权限与 一致) /// /// /// 示例:POST /api/app/us-app-labeling/get-label-report /// ```json /// { "locationId": "3a21220f-db37-3e32-7390-d55f64cd62a8", "startDate": "2026-04-07", "endDate": "2026-05-18" } /// ``` /// [Authorize] [HttpPost] public virtual async Task GetLabelReportAsync(UsAppLabelReportQueryInputVo input) { if (input is null) { throw new UserFriendlyException("入参不能为空"); } if (!CurrentUser.Id.HasValue) { throw new UserFriendlyException("用户未登录"); } var locationId = input.LocationId?.Trim(); if (string.IsNullOrWhiteSpace(locationId)) { throw new UserFriendlyException("门店Id不能为空"); } await UsAppPrintLogScopeHelper.EnsureUserBoundToLocationAsync( CurrentUser, _dbContext.SqlSugarClient, locationId); var canViewAll = await UsAppPrintLogScopeHelper.CanViewAllPrintsAtLocationAsync( CurrentUser, _dbContext.SqlSugarClient); var restrictToCreator = !canViewAll; var currentUserIdStr = CurrentUser.Id.Value.ToString(); var keyword = input.Keyword?.Trim(); var (curStart, curEndExcl) = ResolveAppDateRange(input.StartDate, input.EndDate); var span = curEndExcl - curStart; if (span.TotalDays < 1) { span = TimeSpan.FromDays(1); } var prevEndExcl = curStart; var prevStart = curStart - span; var db = _dbContext.SqlSugarClient; ISugarQueryable Core() => UsAppPrintLogScopeHelper.BuildLocationPrintTaskReportQuery( db, locationId, restrictToCreator, currentUserIdStr, keyword); var totalCur = await Core() .Where((t, l, p, lc, pc, lt, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= curStart && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < curEndExcl) .CountAsync(); var totalPrev = await Core() .Where((t, l, p, lc, pc, lt, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= prevStart && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < prevEndExcl) .CountAsync(); var dayCount = Math.Max(1, (int)Math.Ceiling((curEndExcl - curStart).TotalDays)); var prevDayCount = Math.Max(1, (int)Math.Ceiling((prevEndExcl - prevStart).TotalDays)); var avgDaily = Math.Round((decimal)totalCur / dayCount, 2); var avgDailyPrev = Math.Round((decimal)totalPrev / prevDayCount, 2); var categoryRows = await Core() .Where((t, l, p, lc, pc, lt, tpl) => l.LabelCategoryId != null && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= curStart && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < curEndExcl) .GroupBy((t, l, p, lc, pc, lt, tpl) => new { lc.Id, lc.CategoryName }) .Select((t, l, p, lc, pc, lt, tpl) => new { lc.Id, lc.CategoryName, Cnt = SqlFunc.AggregateCount(t.Id) }) .ToListAsync(); var topCat = categoryRows.OrderByDescending(x => x.Cnt).FirstOrDefault(); var productRows = await Core() .Where((t, l, p, lc, pc, lt, tpl) => !string.IsNullOrEmpty(p.Id) && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= curStart && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < curEndExcl) .GroupBy((t, l, p, lc, pc, lt, tpl) => new { p.Id, p.ProductName, Cat = pc.CategoryName }) .Select((t, l, p, lc, pc, lt, tpl) => new { p.Id, p.ProductName, CategoryName = pc.CategoryName, Cnt = SqlFunc.AggregateCount(t.Id) }) .ToListAsync(); var topProd = productRows.OrderByDescending(x => x.Cnt).FirstOrDefault(); var topList = productRows.OrderByDescending(x => x.Cnt).Take(20).ToList(); var trendEndDay = curEndExcl.Date.AddDays(-1); var trendStartDay = trendEndDay.AddDays(-6); if (trendStartDay < curStart.Date) { trendStartDay = curStart.Date; } var trendEndExcl = trendEndDay.AddDays(1); var trendRaw = await Core() .Where((t, l, p, lc, pc, lt, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime) >= trendStartDay && SqlFunc.IsNull(t.PrintedAt, t.CreationTime) < trendEndExcl) .Select((t, l, p, lc, pc, lt, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime)) .ToListAsync(); var trendDict = trendRaw .Where(x => x.HasValue) .GroupBy(x => x!.Value.Date) .ToDictionary(g => g.Key, g => g.Count()); var trend = new List(); for (var d = trendStartDay; d <= trendEndDay; d = d.AddDays(1)) { trend.Add(new ReportsDailyCountDto { Date = d.ToString("yyyy-MM-dd"), Count = trendDict.TryGetValue(d, out var c) ? c : 0 }); } var byCategory = categoryRows .OrderByDescending(x => x.Cnt) .Select(x => new ReportsCategoryCountDto { CategoryId = string.IsNullOrWhiteSpace(x.Id) ? null : x.Id.Trim(), CategoryName = string.IsNullOrWhiteSpace(x.CategoryName) ? null : x.CategoryName.Trim(), Count = x.Cnt }) .ToList(); var mostUsed = topList.Select(x => { var pct = totalCur <= 0 ? 0m : Math.Round(x.Cnt * 100m / totalCur, 2); return new ReportsTopProductRowDto { ProductId = string.IsNullOrWhiteSpace(x.Id) ? null : x.Id.Trim(), ProductName = string.IsNullOrWhiteSpace(x.ProductName) ? null : x.ProductName.Trim(), CategoryName = string.IsNullOrWhiteSpace(x.CategoryName) ? null : x.CategoryName!.Trim(), TotalPrinted = x.Cnt, UsagePercent = pct }; }).ToList(); return new ReportsLabelReportOutputDto { Summary = new ReportsLabelReportSummaryDto { TotalLabelsPrinted = totalCur, TotalLabelsPrintedPrevPeriod = totalPrev, TotalLabelsPrintedChangeRate = CalcAppChangeRate(totalCur, totalPrev), MostPrintedCategoryName = string.IsNullOrWhiteSpace(topCat?.CategoryName) ? null : topCat.CategoryName.Trim(), MostPrintedCategoryCount = topCat?.Cnt ?? 0, TopProductName = string.IsNullOrWhiteSpace(topProd?.ProductName) ? null : topProd.ProductName.Trim(), TopProductCount = topProd?.Cnt ?? 0, AvgDailyPrints = avgDaily, AvgDailyPrintsPrevPeriod = avgDailyPrev, AvgDailyPrintsChangeRate = CalcAppChangeRate(avgDaily, avgDailyPrev) }, LabelsByCategory = byCategory, PrintVolumeTrend = trend, MostUsedProducts = mostUsed }; } private static (DateTime rangeStart, DateTime rangeEndExcl) ResolveAppDateRange(DateTime? startDate, DateTime? endDate) { var endDay = (endDate ?? DateTime.Today).Date; var endExcl = endDay.AddDays(1); var start = (startDate ?? endDay.AddDays(-29)).Date; if (start >= endExcl) { start = endExcl.AddDays(-1); } return (start, endExcl); } private static decimal CalcAppChangeRate(decimal current, decimal previous) { if (previous == 0) { return current > 0 ? 100m : 0m; } return Math.Round((current - previous) * 100m / previous, 2); } private async Task ResolveTemplateProductDefaultValuesJsonAsync( string templateId, string? productId, string labelTypeId) { if (string.IsNullOrWhiteSpace(templateId) || string.IsNullOrWhiteSpace(productId) || string.IsNullOrWhiteSpace(labelTypeId)) { return null; } var productDefault = await _dbContext.SqlSugarClient.Queryable() .Where(x => x.TemplateId == templateId) .Where(x => x.ProductId == productId) .Where(x => x.LabelTypeId == labelTypeId) .OrderBy(x => x.OrderNum) .FirstAsync(); return string.IsNullOrWhiteSpace(productDefault?.DefaultValuesJson) ? null : productDefault!.DefaultValuesJson; } private ISugarQueryable BuildLabelingJoinQuery( string locationId, List productIds, string? filterCategoryId, string? keyword) { var q = _dbContext.SqlSugarClient .Queryable() .InnerJoin((lp, l) => lp.LabelId == l.Id) .InnerJoin((lp, l, p) => lp.ProductId == p.Id) .InnerJoin((lp, l, p, c) => l.LabelCategoryId == c.Id) .InnerJoin((lp, l, p, c, t) => l.LabelTypeId == t.Id) .InnerJoin((lp, l, p, c, t, tpl) => l.TemplateId == tpl.Id) .LeftJoin((lp, l, p, c, t, tpl, pc) => p.CategoryId == pc.Id) .Where((lp, l, p, c, t, tpl, pc) => productIds.Contains(p.Id)) .Where((lp, l, p, c, t, tpl, pc) => l.LocationId == locationId) .Where((lp, l, p, c, t, tpl, pc) => !l.IsDeleted && l.State) .Where((lp, l, p, c, t, tpl, pc) => !p.IsDeleted && p.State) .Where((lp, l, p, c, t, tpl, pc) => !c.IsDeleted && c.State) .Where((lp, l, p, c, t, tpl, pc) => !t.IsDeleted && t.State) .Where((lp, l, p, c, t, tpl, pc) => !tpl.IsDeleted) .Where((lp, l, p, c, t, tpl, pc) => pc.Id == null || (!pc.IsDeleted && pc.State)) .WhereIF(!string.IsNullOrWhiteSpace(filterCategoryId), (lp, l, p, c, t, tpl, pc) => l.LabelCategoryId == filterCategoryId) .WhereIF(!string.IsNullOrWhiteSpace(keyword), (lp, l, p, c, t, tpl, pc) => (l.LabelName != null && l.LabelName.Contains(keyword!)) || (p.ProductName != null && p.ProductName.Contains(keyword!)) || (pc.CategoryName != null && pc.CategoryName.Contains(keyword!)) || (pc.DisplayText != null && pc.DisplayText.Contains(keyword!)) || (c.CategoryName != null && c.CategoryName.Contains(keyword!)) || (t.TypeName != null && t.TypeName.Contains(keyword!)) || (l.LabelCode != null && l.LabelCode.Contains(keyword!))); return q; } private sealed class LabelingTreeRow { public string LabelCategoryId { get; set; } = string.Empty; public string? LabelCategoryName { get; set; } public string? LabelCategoryPhotoUrl { get; set; } public string? LabelCategoryButtonAppearance { get; set; } public int LabelCategoryOrderNum { get; set; } public string? ProductCategoryId { get; set; } public string? ProductCategoryName { get; set; } public string? ProductCategoryPhotoUrl { get; set; } public string? ProductCategoryDisplayText { get; set; } public string? ProductCategoryButtonAppearance { get; set; } public string? ProductCategoryAvailabilityType { get; set; } public int ProductCategoryOrderNum { get; set; } public string ProductId { get; set; } = string.Empty; public string? ProductName { get; set; } public string? ProductCode { get; set; } public string? ProductImageUrl { get; set; } public string LabelTypeId { get; set; } = string.Empty; public string? TypeName { get; set; } public int TypeOrderNum { get; set; } public string LabelCode { get; set; } = string.Empty; public string TemplateId { get; set; } = string.Empty; public string? TemplateCode { get; set; } public decimal TemplateWidth { get; set; } public decimal TemplateHeight { get; set; } public string TemplateUnit { get; set; } = "inch"; } /// /// 将 App 入参中的 JsonElement(对象或 null)反序列化为 PreviewAsync 所需的扁平字典。 /// private static Dictionary? ParsePrintInputJsonToDictionary(JsonElement? printInputJson) { if (printInputJson is null) { return null; } var je = printInputJson.Value; if (je.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) { return null; } try { return JsonSerializer.Deserialize>(je.GetRawText()); } catch { return null; } } private static string NormalizeCategoryName(string? categoryName) { var s = categoryName?.Trim(); return string.IsNullOrWhiteSpace(s) ? "无" : s; } private static string? NormalizeNullableId(string? id) { var s = id?.Trim(); return string.IsNullOrWhiteSpace(s) ? null : s; } private static string? NormalizeNullableUrl(string? url) { var s = url?.Trim(); return string.IsNullOrWhiteSpace(s) ? null : s; } private async Task ResolvePreviewProductIdAsync(string labelId, string? productId) { var resolvedProductId = productId?.Trim(); if (!string.IsNullOrWhiteSpace(resolvedProductId)) { return resolvedProductId; } return await _dbContext.SqlSugarClient.Queryable() .Where(x => x.LabelId == labelId) .Select(x => x.ProductId) .FirstAsync(); } private static UsAppLabelTypeNodeDto BuildLabelTypeNode(LabelingTreeRow r) { return new UsAppLabelTypeNodeDto { LabelTypeId = r.LabelTypeId, TypeName = r.TypeName ?? string.Empty, OrderNum = r.TypeOrderNum, LabelCode = r.LabelCode ?? string.Empty, TemplateCode = r.TemplateCode, LabelSizeText = FormatLabelSize(r.TemplateWidth, r.TemplateHeight, r.TemplateUnit) }; } private static string? FormatLabelSize(decimal w, decimal h, string unit) { var u = (unit ?? "inch").Trim().ToLowerInvariant(); var ws = w.ToString(CultureInfo.InvariantCulture); var hs = h.ToString(CultureInfo.InvariantCulture); return u is "inch" or "in" ? $"{ws}\"x{hs}\"" : $"{ws}x{hs}{u}"; } private static string? FormatLabelSizeWithUnit(decimal w, decimal h, string unit) { var u = (unit ?? "inch").Trim().ToLowerInvariant(); var ws = w.ToString(CultureInfo.InvariantCulture); var hs = h.ToString(CultureInfo.InvariantCulture); var normalizedUnit = u is "in" ? "inch" : u; return $"{ws}x{hs}{normalizedUnit}"; } }