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}";
}
}