feat: initialize manufacturing progress dashboard

This commit is contained in:
czc
2026-07-13 18:07:59 +08:00
commit 658a23b630
87 changed files with 19195 additions and 0 deletions
@@ -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));
}
}
}