using System.Globalization;
using System.Text.Json;
using FoodLabeling.Application.Contracts.Dtos.Dashboard;
using FoodLabeling.Application.Contracts.IServices;
using FoodLabeling.Application.Services.DbModels;
using FoodLabeling.Domain.Entities;
using SqlSugar;
using Volo.Abp.Application.Services;
using Yi.Framework.Rbac.Domain.Entities;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace FoodLabeling.Application.Services;
///
/// Dashboard 统计服务(美国版)
///
public class DashboardAppService : ApplicationService, IDashboardAppService
{
private readonly ISqlSugarDbContext _dbContext;
private readonly ISqlSugarRepository _locationRepository;
private readonly ISqlSugarRepository _userRepository;
public DashboardAppService(
ISqlSugarDbContext dbContext,
ISqlSugarRepository locationRepository,
ISqlSugarRepository userRepository)
{
_dbContext = dbContext;
_locationRepository = locationRepository;
_userRepository = userRepository;
}
///
public async Task GetOverviewAsync()
{
var now = DateTime.Now;
var todayStart = now.Date;
var tomorrowStart = todayStart.AddDays(1);
var yesterdayStart = todayStart.AddDays(-1);
var weekStart = todayStart.AddDays(-6);
var prevWeekStart = todayStart.AddDays(-13);
var printedToday = await _dbContext.SqlSugarClient.Queryable()
.CountAsync(x => x.CreationTime >= todayStart && x.CreationTime < tomorrowStart);
var printedYesterday = await _dbContext.SqlSugarClient.Queryable()
.CountAsync(x => x.CreationTime >= yesterdayStart && x.CreationTime < todayStart);
var activeTemplates = await _dbContext.SqlSugarClient.Queryable()
.CountAsync(x => !x.IsDeleted && x.State);
var activeTemplatesPrevWeek = await _dbContext.SqlSugarClient.Queryable()
.CountAsync(x => !x.IsDeleted && x.State && x.CreationTime < weekStart);
var activeUsers = await _userRepository._DbQueryable
.CountAsync(x => !x.IsDeleted && x.State);
var activeUsersPrevWeek = await _userRepository._DbQueryable
.CountAsync(x => !x.IsDeleted && x.State && x.CreationTime < weekStart);
var locations = await _locationRepository._DbQueryable
.CountAsync(x => !x.IsDeleted);
var locationsPrevWeek = await _locationRepository._DbQueryable
.CountAsync(x => !x.IsDeleted && x.CreationTime < weekStart);
var people = await _userRepository._DbQueryable
.CountAsync(x => !x.IsDeleted);
var peoplePrevWeek = await _userRepository._DbQueryable
.CountAsync(x => !x.IsDeleted && x.CreationTime < weekStart);
var products = await _dbContext.SqlSugarClient.Queryable()
.CountAsync(x => !x.IsDeleted);
// fl_product 当前实体未映射 CreationTime,无法按时间切分对比,先回退为同口径总量对比
var productsPrevWeek = products;
var weeklyPrintRaw = await _dbContext.SqlSugarClient.Queryable()
.Where(x => x.CreationTime >= weekStart && x.CreationTime < tomorrowStart)
.Select(x => x.CreationTime)
.ToListAsync();
var weeklyDict = weeklyPrintRaw
.GroupBy(x => x.Date)
.ToDictionary(g => g.Key, g => g.Count());
var weeklyTrend = Enumerable.Range(0, 7)
.Select(i =>
{
var d = weekStart.AddDays(i).Date;
return new DashboardDailyTrendPointDto
{
Date = d.ToString("yyyy-MM-dd"),
Value = weeklyDict.TryGetValue(d, out var v) ? v : 0
};
})
.ToList();
var categories = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted && x.State)
.ToListAsync();
var labelCategoryIds = categories.Select(x => x.Id).ToList();
var labelRows = labelCategoryIds.Count == 0
? new List()
: await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted && x.LabelCategoryId != null && labelCategoryIds.Contains(x.LabelCategoryId))
.Select(x => x.LabelCategoryId)
.ToListAsync();
var labelCountByCategory = labelRows
.Where(x => !string.IsNullOrWhiteSpace(x))
.GroupBy(x => x!)
.ToDictionary(g => g.Key, g => g.Count());
var categoryDistributionTotal = labelCountByCategory.Values.Sum();
var categoryDistribution = categories
.Select(c =>
{
var count = labelCountByCategory.TryGetValue(c.Id, out var v) ? v : 0;
var ratio = categoryDistributionTotal == 0
? 0m
: Math.Round(count * 100m / categoryDistributionTotal, 2);
return new DashboardCategoryDistributionDto
{
CategoryId = c.Id,
CategoryName = c.CategoryName,
Count = count,
Ratio = ratio
};
})
.Where(x => x.Count > 0)
.OrderByDescending(x => x.Count)
.ThenBy(x => x.CategoryName)
.ToList();
const int recentLabelsTake = 10;
var recentRaw = await _dbContext.SqlSugarClient.Queryable()
.LeftJoin((t, l) => t.LabelId == l.Id)
.LeftJoin((t, l, p) => t.ProductId == p.Id)
.LeftJoin((t, l, p, tpl) => t.TemplateId == tpl.Id)
.OrderBy((t, l, p, tpl) => SqlFunc.IsNull(t.PrintedAt, t.CreationTime), OrderByType.Desc)
.Take(recentLabelsTake)
.Select((t, l, p, tpl) => new
{
t.Id,
LabelCode = l.LabelCode,
LabelName = l.LabelName,
ProductName = p.ProductName,
tpl.Width,
tpl.Height,
tpl.Unit,
t.PrintInputJson,
PrintedAt = SqlFunc.IsNull(t.PrintedAt, t.CreationTime),
t.CreatedBy
})
.ToListAsync();
var recentUserIds = recentRaw
.Select(x => x.CreatedBy)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x!.Trim())
.Distinct()
.Select(x => Guid.TryParse(x, out var g) ? g : (Guid?)null)
.Where(x => x.HasValue)
.Select(x => x!.Value)
.ToList();
var recentUserMap = new Dictionary(StringComparer.OrdinalIgnoreCase);
if (recentUserIds.Count > 0)
{
var users = await _userRepository._DbQueryable
.Where(u => !u.IsDeleted && recentUserIds.Contains(u.Id))
.Select(u => new { u.Id, u.Name, u.UserName })
.ToListAsync();
foreach (var u in users)
{
var display = !string.IsNullOrWhiteSpace(u.Name) ? u.Name.Trim() : u.UserName.Trim();
recentUserMap[u.Id.ToString()] = string.IsNullOrWhiteSpace(display) ? "无" : display;
}
}
var recentLabels = recentRaw.Select(x =>
{
var displayName = !string.IsNullOrWhiteSpace(x.ProductName)
? x.ProductName.Trim()
: (string.IsNullOrWhiteSpace(x.LabelName) ? "无" : x.LabelName.Trim());
var printedAt = x.PrintedAt ?? DateTime.MinValue;
var status = ResolveRecentLabelStatus(x.PrintInputJson);
return new DashboardRecentLabelItemDto
{
TaskId = x.Id,
LabelCode = string.IsNullOrWhiteSpace(x.LabelCode) ? "无" : x.LabelCode.Trim(),
DisplayName = displayName,
PrintedByUserId = x.CreatedBy?.Trim(),
PrintedByName = ResolveRecentUserName(recentUserMap, x.CreatedBy),
PrintedAt = printedAt,
Status = status,
LabelTypeBadge = FormatTemplateBadge(x.Width, x.Height, x.Unit)
};
}).ToList();
var labelsPrintedTodayCard = BuildMetricCard("labelsPrintedToday", "Labels Printed Today", printedToday, printedYesterday);
var activeTemplatesCard = BuildMetricCard("activeTemplates", "Active Templates", activeTemplates, activeTemplatesPrevWeek);
var activeUsersCard = BuildMetricCard("activeUsers", "Active Users", activeUsers, activeUsersPrevWeek);
var locationsCard = BuildMetricCard("locations", "Locations", locations, locationsPrevWeek);
var peopleCard = BuildMetricCard("people", "People", people, peoplePrevWeek);
var productsCard = BuildMetricCard("products", "Products", products, productsPrevWeek);
var output = new DashboardOverviewOutputDto
{
LabelsPrintedToday = labelsPrintedTodayCard,
ActiveTemplates = activeTemplatesCard,
ActiveUsers = activeUsersCard,
Locations = locationsCard,
People = peopleCard,
Products = productsCard,
MetricCards = new List
{
labelsPrintedTodayCard,
activeTemplatesCard,
activeUsersCard,
locationsCard,
peopleCard,
productsCard
},
WeeklyPrintVolume = weeklyTrend,
CategoryDistribution = categoryDistribution,
CategoryDistributionTotal = categoryDistributionTotal,
ByCategory = categoryDistribution,
ByCategoryTotal = categoryDistributionTotal,
RecentLabels = recentLabels,
GeneratedAt = now
};
return output;
}
private static string ResolveRecentUserName(Dictionary map, string? createdBy)
{
if (string.IsNullOrWhiteSpace(createdBy))
{
return "无";
}
return map.TryGetValue(createdBy.Trim(), out var n) ? n : "无";
}
///
/// 依据 PrintInputJson 中的保质期字段与「当前日期」比较得到 active/expired。
///
private static string ResolveRecentLabelStatus(string? printInputJson)
{
if (!TryParseExpiryDate(printInputJson, out var expiryDate))
{
return "active";
}
var today = DateTime.Now.Date;
return expiryDate.Date < today ? "expired" : "active";
}
private static bool TryParseExpiryDate(string? printInputJson, out DateTime expiryDate)
{
expiryDate = default;
if (string.IsNullOrWhiteSpace(printInputJson))
{
return false;
}
try
{
using var doc = JsonDocument.Parse(printInputJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
foreach (var prop in doc.RootElement.EnumerateObject())
{
var key = prop.Name.Trim();
if (!key.Equals("expiryDate", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("expiry", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("expirationDate", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var v = prop.Value;
if (v.ValueKind == JsonValueKind.String)
{
var s = v.GetString();
if (!string.IsNullOrWhiteSpace(s) &&
DateTime.TryParse(s, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dt))
{
expiryDate = dt;
return true;
}
}
else if (v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var unix))
{
expiryDate = DateTimeOffset.FromUnixTimeSeconds(unix).LocalDateTime;
return true;
}
}
}
catch
{
return false;
}
return false;
}
private static string FormatTemplateBadge(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 DashboardMetricCardDto BuildMetricCard(string key, string title, int value, int previousValue)
{
var changeValue = value - previousValue;
var changeRate = previousValue <= 0
? (value > 0 ? 100m : 0m)
: Math.Round(changeValue * 100m / previousValue, 2);
return new DashboardMetricCardDto
{
Key = key,
Title = title,
Value = value,
PreviousValue = previousValue,
ChangeValue = changeValue,
ChangeRate = changeRate
};
}
}