feat: initialize manufacturing progress dashboard
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
using DongfangHydro.Dashboard.Api.Contracts;
|
||||
using DongfangHydro.Dashboard.Api.Domain;
|
||||
|
||||
namespace DongfangHydro.Dashboard.Api.Services;
|
||||
|
||||
public static class DashboardMapper
|
||||
{
|
||||
public static ProductionNodeDto? BuildTree(IReadOnlyCollection<ProductionNode> nodes)
|
||||
{
|
||||
var root = nodes
|
||||
.Where(node => node.ParentNodeId is null)
|
||||
.OrderBy(node => node.SortOrder)
|
||||
.FirstOrDefault(node => node.NodeType == "order")
|
||||
?? nodes.Where(node => node.ParentNodeId is null).OrderBy(node => node.SortOrder).FirstOrDefault();
|
||||
|
||||
if (root is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var childrenByParent = nodes
|
||||
.Where(node => node.ParentNodeId.HasValue)
|
||||
.GroupBy(node => node.ParentNodeId!.Value)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.OrderBy(node => node.SortOrder).ThenBy(node => node.Code).ToList());
|
||||
|
||||
return MapNode(root, childrenByParent, []);
|
||||
}
|
||||
|
||||
public static ProductionNodeDto MapNode(ProductionNode node)
|
||||
{
|
||||
return MapNode(node, new Dictionary<Guid, List<ProductionNode>>(), []);
|
||||
}
|
||||
|
||||
public static TrendPointDto MapTrend(ProductionTrendPoint point)
|
||||
{
|
||||
return new TrendPointDto(
|
||||
point.SampleTime.ToLocalTime().ToString("HH:mm:ss"),
|
||||
point.Completion,
|
||||
point.Risk,
|
||||
point.PlannedQty,
|
||||
point.ActualQty,
|
||||
Math.Round(point.Achievement, 1));
|
||||
}
|
||||
|
||||
public static RiskEventDto MapRiskEvent(RiskEvent riskEvent)
|
||||
{
|
||||
return new RiskEventDto(
|
||||
riskEvent.Id,
|
||||
riskEvent.OrderId,
|
||||
riskEvent.NodeId,
|
||||
riskEvent.OrderCode,
|
||||
riskEvent.NodeName,
|
||||
riskEvent.RiskLevel,
|
||||
riskEvent.Message,
|
||||
riskEvent.OccurredAt.ToLocalTime().ToString("HH:mm:ss"),
|
||||
riskEvent.HandlingStatus);
|
||||
}
|
||||
|
||||
public static OrderSummaryDto MapOrder(
|
||||
ProductionOrder order,
|
||||
ProductionNodeDto root,
|
||||
IReadOnlyList<TrendPointDto> trend,
|
||||
IReadOnlyList<RiskEventDto> events)
|
||||
{
|
||||
return new OrderSummaryDto(
|
||||
order.Id,
|
||||
order.Code,
|
||||
order.ProductName,
|
||||
order.BatchQty,
|
||||
order.RequiredQty,
|
||||
order.CompletedQty,
|
||||
order.Progress,
|
||||
order.Status,
|
||||
order.RiskLevel,
|
||||
order.DelayDays,
|
||||
FormatDate(order.PlannedStart),
|
||||
FormatDate(order.PlannedEnd),
|
||||
order.LineName,
|
||||
order.Owner,
|
||||
Math.Round(order.PlanAchievement, 1),
|
||||
Math.Round(order.DailyDelta, 1),
|
||||
order.ThumbnailKey,
|
||||
root,
|
||||
trend,
|
||||
events);
|
||||
}
|
||||
|
||||
private static ProductionNodeDto MapNode(
|
||||
ProductionNode node,
|
||||
IReadOnlyDictionary<Guid, List<ProductionNode>> childrenByParent,
|
||||
HashSet<Guid> ancestors)
|
||||
{
|
||||
if (!ancestors.Add(node.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Production node cycle detected at {node.Id}.");
|
||||
}
|
||||
|
||||
var children = childrenByParent.TryGetValue(node.Id, out var childNodes)
|
||||
? childNodes.Select(child => MapNode(child, childrenByParent, new HashSet<Guid>(ancestors))).ToList()
|
||||
: [];
|
||||
|
||||
return new ProductionNodeDto(
|
||||
node.Id,
|
||||
node.OrderId,
|
||||
node.ParentNodeId,
|
||||
node.Code,
|
||||
node.OperationCode,
|
||||
node.MaterialCode,
|
||||
node.Name,
|
||||
node.SourceSequence,
|
||||
node.Specification,
|
||||
node.Material,
|
||||
node.UnitWeight,
|
||||
node.TotalWeight,
|
||||
node.UnitWeightText,
|
||||
node.TotalWeightText,
|
||||
node.BomQuantity,
|
||||
node.BomQuantityText,
|
||||
node.Remark,
|
||||
node.SourceSheet,
|
||||
node.SourceRow,
|
||||
node.Level,
|
||||
node.NodeType,
|
||||
node.SupplyType,
|
||||
node.RequiredQty,
|
||||
node.CompletedQty,
|
||||
node.DefectQty,
|
||||
node.Progress,
|
||||
node.Status,
|
||||
node.RiskLevel,
|
||||
node.DelayDays,
|
||||
node.DelayReason,
|
||||
FormatDate(node.PlannedStart),
|
||||
FormatDate(node.PlannedEnd),
|
||||
FormatDate(node.ActualStart),
|
||||
FormatDate(node.ActualEnd),
|
||||
node.Owner,
|
||||
node.Vendor,
|
||||
node.StationName,
|
||||
node.VisualKey,
|
||||
Convert.ToBase64String(node.RowVersion),
|
||||
children);
|
||||
}
|
||||
|
||||
private static string FormatDate(DateTime? value)
|
||||
{
|
||||
return value.HasValue ? value.Value.ToString("yyyy-MM-dd") : string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using DongfangHydro.Dashboard.Api.Contracts;
|
||||
using DongfangHydro.Dashboard.Api.Data;
|
||||
using DongfangHydro.Dashboard.Api.Domain;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DongfangHydro.Dashboard.Api.Services;
|
||||
|
||||
public sealed class DashboardQueryService(DashboardDbContext dbContext)
|
||||
{
|
||||
public async Task<PagedResult<MaterialQuotaDto>> GetMaterialQuotasAsync(
|
||||
Guid orderId,
|
||||
string? category,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 200);
|
||||
var query = dbContext.MaterialQuotas.AsNoTracking().Where(item => item.OrderId == orderId);
|
||||
if (!string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
var normalizedCategory = category.Trim();
|
||||
query = query.Where(item => item.Category == normalizedCategory);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalizedKeyword = keyword.Trim();
|
||||
query = query.Where(item =>
|
||||
item.MaterialCode.Contains(normalizedKeyword)
|
||||
|| item.MaterialName.Contains(normalizedKeyword)
|
||||
|| item.Specification.Contains(normalizedKeyword));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var rows = await query
|
||||
.OrderBy(item => item.Category)
|
||||
.ThenBy(item => item.SourceRow)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
var items = rows.Select(item => new MaterialQuotaDto(
|
||||
item.Id,
|
||||
item.OrderId,
|
||||
item.Category,
|
||||
item.SourceSequence,
|
||||
item.MaterialCode,
|
||||
item.MaterialName,
|
||||
item.Specification,
|
||||
item.Unit,
|
||||
item.Quantity,
|
||||
item.NetWeight,
|
||||
item.ConsumptionQuota,
|
||||
item.QuantityText,
|
||||
item.NetWeightText,
|
||||
item.ConsumptionQuotaText,
|
||||
item.Brand,
|
||||
item.Remark,
|
||||
item.SourceSheet,
|
||||
item.SourceRow)).ToList();
|
||||
|
||||
return new PagedResult<MaterialQuotaDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<OrderSummaryDto>> GetOrdersAsync(
|
||||
string? keyword,
|
||||
string? status,
|
||||
Guid? lineId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var query = dbContext.ProductionOrders.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(order =>
|
||||
order.Code.Contains(normalized)
|
||||
|| order.ProductName.Contains(normalized)
|
||||
|| order.LineName.Contains(normalized)
|
||||
|| order.Owner.Contains(normalized));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
query = query.Where(order => order.Status == status);
|
||||
}
|
||||
|
||||
if (lineId.HasValue)
|
||||
{
|
||||
query = query.Where(order => order.LineId == lineId);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var orders = await query
|
||||
.OrderBy(order => order.Code)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
var items = await BuildOrderSummariesAsync(orders, cancellationToken);
|
||||
|
||||
return new PagedResult<OrderSummaryDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<OrderSummaryDto?> GetOrderAsync(Guid orderId, CancellationToken cancellationToken)
|
||||
{
|
||||
var order = await dbContext.ProductionOrders
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == orderId, cancellationToken);
|
||||
if (order is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await BuildOrderSummariesAsync([order], cancellationToken)).SingleOrDefault();
|
||||
}
|
||||
|
||||
public async Task<ProductionNodeDto?> GetTreeAsync(Guid orderId, CancellationToken cancellationToken)
|
||||
{
|
||||
var nodes = await dbContext.ProductionNodes
|
||||
.AsNoTracking()
|
||||
.Where(node => node.OrderId == orderId)
|
||||
.OrderBy(node => node.Level)
|
||||
.ThenBy(node => node.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return DashboardMapper.BuildTree(nodes);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrendPointDto>> GetTrendAsync(
|
||||
Guid orderId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var points = await dbContext.ProductionTrendPoints
|
||||
.AsNoTracking()
|
||||
.Where(point => point.OrderId == orderId)
|
||||
.OrderByDescending(point => point.SampleTime)
|
||||
.Take(Math.Clamp(limit, 1, 100))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return points.OrderBy(point => point.SampleTime).Select(DashboardMapper.MapTrend).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RiskEventDto>> GetRiskEventsAsync(
|
||||
Guid? orderId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = dbContext.RiskEvents.AsNoTracking();
|
||||
if (orderId.HasValue)
|
||||
{
|
||||
query = query.Where(riskEvent => riskEvent.OrderId == orderId);
|
||||
}
|
||||
|
||||
var events = await query
|
||||
.OrderByDescending(riskEvent => riskEvent.OccurredAt)
|
||||
.Take(Math.Clamp(limit, 1, 100))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return events.Select(DashboardMapper.MapRiskEvent).ToList();
|
||||
}
|
||||
|
||||
public async Task<DashboardOverviewDto> GetOverviewAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var orders = await dbContext.ProductionOrders.AsNoTracking().ToListAsync(cancellationToken);
|
||||
var totalRequired = orders.Sum(order => order.RequiredQty);
|
||||
var totalCompleted = orders.Sum(order => order.CompletedQty);
|
||||
|
||||
return new DashboardOverviewDto(
|
||||
orders.Count,
|
||||
totalRequired,
|
||||
totalCompleted,
|
||||
totalRequired == 0 ? 0 : Math.Round(totalCompleted * 100d / totalRequired, 1),
|
||||
orders.Count(order => order.DelayDays > 0 || order.Status is "delayed" or "blocked"),
|
||||
orders.Count(order => order.RiskLevel == "critical"),
|
||||
orders.Count(order => order.RiskLevel == "warning"),
|
||||
orders.Count == 0 ? 0 : Math.Round(orders.Average(order => order.PlanAchievement), 1),
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DelayTopItemDto>> GetDelayTopAsync(
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var orders = await dbContext.ProductionOrders
|
||||
.AsNoTracking()
|
||||
.Where(order => order.DelayDays > 0 || order.Status == "delayed" || order.Status == "blocked")
|
||||
.OrderByDescending(order => order.DelayDays)
|
||||
.ThenByDescending(order => order.RiskLevel == "critical")
|
||||
.Take(Math.Clamp(limit, 1, 50))
|
||||
.ToListAsync(cancellationToken);
|
||||
var orderIds = orders.Select(order => order.Id).ToArray();
|
||||
var recentEvents = await LoadRecentRiskEventsAsync(orderIds, 1, cancellationToken);
|
||||
var result = new List<DelayTopItemDto>(orders.Count);
|
||||
for (var index = 0; index < orders.Count; index++)
|
||||
{
|
||||
var order = orders[index];
|
||||
var delayReason = recentEvents.FirstOrDefault(riskEvent => riskEvent.OrderId == order.Id)?.Message;
|
||||
result.Add(new DelayTopItemDto(
|
||||
index + 1,
|
||||
order.Id,
|
||||
order.Code,
|
||||
order.ProductName,
|
||||
order.DelayDays,
|
||||
delayReason ?? "计划节拍低于目标",
|
||||
order.RiskLevel));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<OrderSummaryDto>> BuildOrderSummariesAsync(
|
||||
IReadOnlyList<ProductionOrder> orders,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (orders.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var orderIds = orders.Select(order => order.Id).ToList();
|
||||
var nodes = await dbContext.ProductionNodes
|
||||
.AsNoTracking()
|
||||
.Where(node => orderIds.Contains(node.OrderId))
|
||||
.OrderBy(node => node.Level)
|
||||
.ThenBy(node => node.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
var trend = await LoadRecentTrendAsync(orderIds, 12, cancellationToken);
|
||||
var events = await LoadRecentRiskEventsAsync(orderIds, 8, cancellationToken);
|
||||
var result = new List<OrderSummaryDto>(orders.Count);
|
||||
foreach (var order in orders)
|
||||
{
|
||||
var root = DashboardMapper.BuildTree(nodes.Where(node => node.OrderId == order.Id).ToList());
|
||||
if (root is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var orderTrend = trend.Where(point => point.OrderId == order.Id);
|
||||
var orderEvents = events
|
||||
.Where(riskEvent => riskEvent.OrderId == order.Id)
|
||||
.OrderByDescending(riskEvent => riskEvent.OccurredAt);
|
||||
|
||||
result.Add(DashboardMapper.MapOrder(
|
||||
order,
|
||||
root,
|
||||
orderTrend.OrderBy(point => point.SampleTime).Select(DashboardMapper.MapTrend).ToList(),
|
||||
orderEvents.Select(DashboardMapper.MapRiskEvent).ToList()));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<List<ProductionTrendPoint>> LoadRecentTrendAsync(
|
||||
IReadOnlyCollection<Guid> orderIds,
|
||||
int perOrderLimit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (orderIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (dbContext.Database.IsSqlServer())
|
||||
{
|
||||
return await BuildRecentTrendQuery(dbContext, orderIds, perOrderLimit)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var points = await dbContext.ProductionTrendPoints
|
||||
.AsNoTracking()
|
||||
.Where(point => orderIds.Contains(point.OrderId))
|
||||
.ToListAsync(cancellationToken);
|
||||
return points
|
||||
.GroupBy(point => point.OrderId)
|
||||
.SelectMany(group => group.OrderByDescending(point => point.SampleTime).Take(perOrderLimit))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<RiskEvent>> LoadRecentRiskEventsAsync(
|
||||
IReadOnlyCollection<Guid> orderIds,
|
||||
int perOrderLimit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (orderIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (dbContext.Database.IsSqlServer())
|
||||
{
|
||||
return await BuildRecentRiskEventQuery(dbContext, orderIds, perOrderLimit)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var events = await dbContext.RiskEvents
|
||||
.AsNoTracking()
|
||||
.Where(riskEvent => orderIds.Contains(riskEvent.OrderId))
|
||||
.ToListAsync(cancellationToken);
|
||||
return events
|
||||
.GroupBy(riskEvent => riskEvent.OrderId)
|
||||
.SelectMany(group => group.OrderByDescending(riskEvent => riskEvent.OccurredAt).Take(perOrderLimit))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static IQueryable<ProductionTrendPoint> BuildRecentTrendQuery(
|
||||
DashboardDbContext dbContext,
|
||||
IReadOnlyCollection<Guid> orderIds,
|
||||
int perOrderLimit)
|
||||
{
|
||||
if (orderIds.Count == 0)
|
||||
{
|
||||
return dbContext.ProductionTrendPoints.Where(_ => false);
|
||||
}
|
||||
|
||||
var (placeholders, parameters) = BuildOrderIdParameters(orderIds);
|
||||
parameters.Add(new SqlParameter("@perOrderLimit", Math.Max(1, perOrderLimit)));
|
||||
var sql = $$"""
|
||||
SELECT [ranked].[Id], [ranked].[OrderId], [ranked].[SampleTime], [ranked].[Completion],
|
||||
[ranked].[Risk], [ranked].[PlannedQty], [ranked].[ActualQty], [ranked].[Achievement]
|
||||
FROM (
|
||||
SELECT [point].*, ROW_NUMBER() OVER (
|
||||
PARTITION BY [point].[OrderId]
|
||||
ORDER BY [point].[SampleTime] DESC, [point].[Id] DESC
|
||||
) AS [row_number]
|
||||
FROM [ProductionTrendPoints] AS [point]
|
||||
WHERE [point].[OrderId] IN ({{placeholders}})
|
||||
) AS [ranked]
|
||||
WHERE [ranked].[row_number] <= @perOrderLimit
|
||||
""";
|
||||
|
||||
return dbContext.ProductionTrendPoints.FromSqlRaw(sql, [.. parameters]);
|
||||
}
|
||||
|
||||
private static IQueryable<RiskEvent> BuildRecentRiskEventQuery(
|
||||
DashboardDbContext dbContext,
|
||||
IReadOnlyCollection<Guid> orderIds,
|
||||
int perOrderLimit)
|
||||
{
|
||||
if (orderIds.Count == 0)
|
||||
{
|
||||
return dbContext.RiskEvents.Where(_ => false);
|
||||
}
|
||||
|
||||
var (placeholders, parameters) = BuildOrderIdParameters(orderIds);
|
||||
parameters.Add(new SqlParameter("@perOrderLimit", Math.Max(1, perOrderLimit)));
|
||||
var sql = $$"""
|
||||
SELECT [ranked].[Id], [ranked].[OrderId], [ranked].[NodeId], [ranked].[OrderCode],
|
||||
[ranked].[NodeName], [ranked].[RiskLevel], [ranked].[Message],
|
||||
[ranked].[HandlingStatus], [ranked].[OccurredAt]
|
||||
FROM (
|
||||
SELECT [event].*, ROW_NUMBER() OVER (
|
||||
PARTITION BY [event].[OrderId]
|
||||
ORDER BY [event].[OccurredAt] DESC, [event].[Id] DESC
|
||||
) AS [row_number]
|
||||
FROM [RiskEvents] AS [event]
|
||||
WHERE [event].[OrderId] IN ({{placeholders}})
|
||||
) AS [ranked]
|
||||
WHERE [ranked].[row_number] <= @perOrderLimit
|
||||
""";
|
||||
|
||||
return dbContext.RiskEvents.FromSqlRaw(sql, [.. parameters]);
|
||||
}
|
||||
|
||||
private static (string Placeholders, List<object> Parameters) BuildOrderIdParameters(
|
||||
IReadOnlyCollection<Guid> orderIds)
|
||||
{
|
||||
var parameters = orderIds
|
||||
.Select((orderId, index) => (object)new SqlParameter($"@orderId{index}", orderId))
|
||||
.ToList();
|
||||
var placeholders = string.Join(", ", Enumerable.Range(0, orderIds.Count).Select(index => $"@orderId{index}"));
|
||||
return (placeholders, parameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using DongfangHydro.Dashboard.Api.Domain;
|
||||
|
||||
namespace DongfangHydro.Dashboard.Api.Services;
|
||||
|
||||
public sealed class ProgressAggregationService
|
||||
{
|
||||
public void Recalculate(ProductionOrder order, IReadOnlyCollection<ProductionNode> nodes)
|
||||
{
|
||||
var childrenByParent = nodes
|
||||
.Where(node => node.ParentNodeId.HasValue)
|
||||
.GroupBy(node => node.ParentNodeId!.Value)
|
||||
.ToDictionary(group => group.Key, group => group.ToList());
|
||||
|
||||
var roots = nodes.Where(node => node.ParentNodeId is null).ToList();
|
||||
foreach (var rootNode in roots)
|
||||
{
|
||||
RecalculateNode(rootNode, childrenByParent);
|
||||
}
|
||||
|
||||
var root = roots.FirstOrDefault(node => node.NodeType == "order") ?? roots.FirstOrDefault();
|
||||
if (root is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
order.RequiredQty = root.RequiredQty;
|
||||
order.CompletedQty = root.CompletedQty;
|
||||
order.Progress = root.Progress;
|
||||
order.Status = root.Status;
|
||||
order.RiskLevel = root.RiskLevel;
|
||||
order.DelayDays = root.DelayDays;
|
||||
}
|
||||
|
||||
private static void RecalculateNode(
|
||||
ProductionNode node,
|
||||
IReadOnlyDictionary<Guid, List<ProductionNode>> childrenByParent)
|
||||
{
|
||||
if (!childrenByParent.TryGetValue(node.Id, out var children) || children.Count == 0)
|
||||
{
|
||||
node.Progress = Percentage(node.CompletedQty, node.RequiredQty);
|
||||
if (node.Progress >= 100)
|
||||
{
|
||||
node.Status = "done";
|
||||
node.RiskLevel = "normal";
|
||||
node.DelayDays = 0;
|
||||
node.DelayReason = string.Empty;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
RecalculateNode(child, childrenByParent);
|
||||
}
|
||||
|
||||
var childRequiredQty = children.Sum(child => child.RequiredQty);
|
||||
var weightedProgress = childRequiredQty == 0
|
||||
? 0
|
||||
: children.Sum(child => child.Progress * child.RequiredQty) / (double)childRequiredQty;
|
||||
node.RequiredQty = node.RequiredQty > 0 ? node.RequiredQty : childRequiredQty;
|
||||
node.CompletedQty = (int)Math.Round(
|
||||
node.RequiredQty * weightedProgress / 100d,
|
||||
MidpointRounding.AwayFromZero);
|
||||
node.DefectQty = children.Sum(child => child.DefectQty);
|
||||
node.Progress = Math.Clamp(
|
||||
(int)Math.Round(weightedProgress, MidpointRounding.AwayFromZero),
|
||||
0,
|
||||
100);
|
||||
node.DelayDays = children.Max(child => child.DelayDays);
|
||||
node.RiskLevel = AggregateRisk(children);
|
||||
node.Status = AggregateStatus(children);
|
||||
}
|
||||
|
||||
private static int Percentage(int completed, int required)
|
||||
{
|
||||
return required <= 0
|
||||
? 0
|
||||
: Math.Clamp((int)Math.Round(completed * 100d / required, MidpointRounding.AwayFromZero), 0, 100);
|
||||
}
|
||||
|
||||
private static string AggregateRisk(IEnumerable<ProductionNode> children)
|
||||
{
|
||||
var childList = children.ToList();
|
||||
if (childList.Any(child =>
|
||||
child.RiskLevel == "critical"
|
||||
|| child.Status == "blocked"
|
||||
|| child.DelayDays >= 3))
|
||||
{
|
||||
return "critical";
|
||||
}
|
||||
|
||||
return childList.Any(child =>
|
||||
child.RiskLevel == "warning"
|
||||
|| child.Status == "delayed"
|
||||
|| child.DelayDays > 0)
|
||||
? "warning"
|
||||
: "normal";
|
||||
}
|
||||
|
||||
private static string AggregateStatus(IReadOnlyCollection<ProductionNode> children)
|
||||
{
|
||||
if (children.Any(child => child.Status == "blocked"))
|
||||
{
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
if (children.Any(child => child.Status == "delayed" || child.DelayDays > 0))
|
||||
{
|
||||
return "delayed";
|
||||
}
|
||||
|
||||
if (children.All(child => child.Status == "done"))
|
||||
{
|
||||
return "done";
|
||||
}
|
||||
|
||||
return children.Any(child => child.Status == "in_progress" || child.CompletedQty > 0)
|
||||
? "in_progress"
|
||||
: "waiting";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using DongfangHydro.Dashboard.Api.Contracts;
|
||||
using DongfangHydro.Dashboard.Api.Data;
|
||||
using DongfangHydro.Dashboard.Api.Domain;
|
||||
using DongfangHydro.Dashboard.Api.Realtime;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DongfangHydro.Dashboard.Api.Services;
|
||||
|
||||
public sealed class ProgressUpdateService(
|
||||
DashboardDbContext dbContext,
|
||||
ProgressAggregationService aggregationService,
|
||||
IDashboardUpdateQueue updateQueue)
|
||||
{
|
||||
public async Task<UpdateNodeProgressResultDto?> UpdateAsync(
|
||||
Guid nodeId,
|
||||
UpdateNodeProgressRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var node = await dbContext.ProductionNodes
|
||||
.SingleOrDefaultAsync(item => item.Id == nodeId, cancellationToken);
|
||||
if (node is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!request.CompletedQty.HasValue)
|
||||
{
|
||||
throw new ArgumentException("Completed quantity is required.", nameof(request.CompletedQty));
|
||||
}
|
||||
|
||||
var completedQty = request.CompletedQty.Value;
|
||||
if (completedQty < 0 || completedQty > node.RequiredQty)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(request.CompletedQty),
|
||||
$"Completed quantity must be between 0 and {node.RequiredQty}.");
|
||||
}
|
||||
|
||||
var order = await dbContext.ProductionOrders
|
||||
.SingleAsync(item => item.Id == node.OrderId, cancellationToken);
|
||||
var nodes = await dbContext.ProductionNodes
|
||||
.Where(item => item.OrderId == node.OrderId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var recentTrend = await dbContext.ProductionTrendPoints
|
||||
.AsNoTracking()
|
||||
.Where(point => point.OrderId == node.OrderId)
|
||||
.OrderByDescending(point => point.SampleTime)
|
||||
.Take(11)
|
||||
.ToListAsync(cancellationToken);
|
||||
var recentEvents = await dbContext.RiskEvents
|
||||
.AsNoTracking()
|
||||
.Where(riskEvent => riskEvent.OrderId == node.OrderId)
|
||||
.OrderByDescending(riskEvent => riskEvent.OccurredAt)
|
||||
.Take(7)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ExpectedVersion))
|
||||
{
|
||||
throw new ArgumentException("Expected version is required.", nameof(request.ExpectedVersion));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
dbContext.Entry(node).Property(item => item.RowVersion).OriginalValue =
|
||||
Convert.FromBase64String(request.ExpectedVersion);
|
||||
}
|
||||
catch (FormatException exception)
|
||||
{
|
||||
throw new ArgumentException("Expected version is not valid Base64.", nameof(request.ExpectedVersion), exception);
|
||||
}
|
||||
|
||||
if (node.NodeType is not ("process" or "material"))
|
||||
{
|
||||
throw new ArgumentException("Only process or material nodes can receive direct progress updates.", nameof(nodeId));
|
||||
}
|
||||
|
||||
var effectiveStatus = NormalizeStatus(request.Status ?? node.Status);
|
||||
if (completedQty > 0 && completedQty < node.RequiredQty && effectiveStatus == "waiting")
|
||||
{
|
||||
effectiveStatus = "in_progress";
|
||||
}
|
||||
var effectiveRisk = NormalizeRisk(request.RiskLevel ?? node.RiskLevel);
|
||||
var effectiveDelayDays = Math.Max(0, request.DelayDays ?? node.DelayDays);
|
||||
var effectiveDelayReason = request.DelayReason ?? node.DelayReason;
|
||||
var effectiveActualStart = request.ActualStart
|
||||
?? node.ActualStart
|
||||
?? (completedQty > 0 ? DateTime.Today : null);
|
||||
var effectiveActualEnd = completedQty >= node.RequiredQty
|
||||
? request.ActualEnd ?? node.ActualEnd ?? DateTime.Today
|
||||
: request.ActualEnd;
|
||||
ValidateState(
|
||||
completedQty,
|
||||
node.RequiredQty,
|
||||
effectiveStatus,
|
||||
effectiveRisk,
|
||||
effectiveDelayDays,
|
||||
effectiveDelayReason,
|
||||
effectiveActualStart,
|
||||
effectiveActualEnd);
|
||||
|
||||
node.CompletedQty = completedQty;
|
||||
node.DefectQty = Math.Clamp(request.DefectQty ?? node.DefectQty, 0, completedQty);
|
||||
node.DelayDays = effectiveDelayDays;
|
||||
node.DelayReason = effectiveDelayReason;
|
||||
node.Status = effectiveStatus;
|
||||
node.RiskLevel = effectiveRisk;
|
||||
node.ActualStart = effectiveActualStart;
|
||||
node.ActualEnd = effectiveActualEnd;
|
||||
node.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
aggregationService.Recalculate(order, nodes);
|
||||
order.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
var plannedQty = Math.Max(
|
||||
order.CompletedQty,
|
||||
(int)Math.Round(order.RequiredQty * Math.Min(100, order.Progress + 8) / 100d));
|
||||
order.PlanAchievement = plannedQty == 0
|
||||
? 0
|
||||
: Math.Min(120, order.CompletedQty * 100d / plannedQty);
|
||||
|
||||
var trendPoint = new ProductionTrendPoint
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = order.Id,
|
||||
SampleTime = DateTimeOffset.UtcNow,
|
||||
Completion = order.Progress,
|
||||
Risk = nodes.Count(item => item.RiskLevel != "normal"),
|
||||
PlannedQty = plannedQty,
|
||||
ActualQty = order.CompletedQty,
|
||||
Achievement = order.PlanAchievement,
|
||||
};
|
||||
dbContext.ProductionTrendPoints.Add(trendPoint);
|
||||
|
||||
RiskEvent? riskEvent = null;
|
||||
if (node.RiskLevel != "normal" || node.DelayDays > 0 || node.Status is "delayed" or "blocked")
|
||||
{
|
||||
riskEvent = new RiskEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = order.Id,
|
||||
NodeId = node.Id,
|
||||
OrderCode = order.Code,
|
||||
NodeName = node.Name,
|
||||
RiskLevel = node.RiskLevel,
|
||||
Message = string.IsNullOrWhiteSpace(node.DelayReason)
|
||||
? $"计划偏差 {Math.Max(1, node.DelayDays)} 天,需要复核节拍"
|
||||
: node.DelayReason,
|
||||
HandlingStatus = "processing",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
dbContext.RiskEvents.Add(riskEvent);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var rootDto = DashboardMapper.BuildTree(nodes)
|
||||
?? throw new InvalidOperationException($"Order {order.Id} has no production tree.");
|
||||
var trendDtos = recentTrend
|
||||
.Append(trendPoint)
|
||||
.OrderBy(point => point.SampleTime)
|
||||
.Select(DashboardMapper.MapTrend)
|
||||
.ToList();
|
||||
var eventDtos = (riskEvent is null ? recentEvents : recentEvents.Prepend(riskEvent))
|
||||
.Take(8)
|
||||
.Select(DashboardMapper.MapRiskEvent)
|
||||
.ToList();
|
||||
var orderDto = DashboardMapper.MapOrder(order, rootDto, trendDtos, eventDtos);
|
||||
var nodeDto = FindNode(orderDto.Root, node.Id)
|
||||
?? throw new InvalidOperationException($"Node {node.Id} disappeared after progress update.");
|
||||
var result = new UpdateNodeProgressResultDto(
|
||||
nodeDto,
|
||||
orderDto,
|
||||
riskEvent is null ? null : DashboardMapper.MapRiskEvent(riskEvent),
|
||||
DashboardMapper.MapTrend(trendPoint));
|
||||
await updateQueue.EnqueueAsync(result, CancellationToken.None);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ProductionNodeDto? FindNode(ProductionNodeDto node, Guid nodeId)
|
||||
{
|
||||
if (node.Id == nodeId)
|
||||
{
|
||||
return node;
|
||||
}
|
||||
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
var found = FindNode(child, nodeId);
|
||||
if (found is not null)
|
||||
{
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeStatus(string value)
|
||||
{
|
||||
return value is "waiting" or "in_progress" or "done" or "delayed" or "blocked"
|
||||
? value
|
||||
: throw new ArgumentException($"Unsupported production status '{value}'.", nameof(value));
|
||||
}
|
||||
|
||||
private static string NormalizeRisk(string value)
|
||||
{
|
||||
return value is "normal" or "warning" or "critical"
|
||||
? value
|
||||
: throw new ArgumentException($"Unsupported risk level '{value}'.", nameof(value));
|
||||
}
|
||||
|
||||
private static void ValidateState(
|
||||
int completedQty,
|
||||
int requiredQty,
|
||||
string status,
|
||||
string riskLevel,
|
||||
int delayDays,
|
||||
string delayReason,
|
||||
DateTime? actualStart,
|
||||
DateTime? actualEnd)
|
||||
{
|
||||
if (delayReason.Length > 500)
|
||||
{
|
||||
throw new ArgumentException("Delay reason cannot exceed 500 characters.", nameof(delayReason));
|
||||
}
|
||||
|
||||
if (completedQty < requiredQty && status == "done")
|
||||
{
|
||||
throw new ArgumentException("A process cannot be done before its planned quantity is complete.", nameof(status));
|
||||
}
|
||||
|
||||
if (completedQty < requiredQty && actualEnd.HasValue)
|
||||
{
|
||||
throw new ArgumentException("An incomplete process cannot have an actual end date.", nameof(actualEnd));
|
||||
}
|
||||
|
||||
|
||||
if (actualStart.HasValue && actualEnd.HasValue && actualEnd.Value < actualStart.Value)
|
||||
{
|
||||
throw new ArgumentException("Actual end date cannot be earlier than actual start date.", nameof(actualEnd));
|
||||
}
|
||||
|
||||
if (status == "blocked" && riskLevel != "critical")
|
||||
{
|
||||
throw new ArgumentException("A blocked process must have critical risk.", nameof(riskLevel));
|
||||
}
|
||||
|
||||
if ((status == "delayed" || delayDays > 0) && riskLevel == "normal")
|
||||
{
|
||||
throw new ArgumentException("A delayed process cannot have normal risk.", nameof(riskLevel));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user