Files
BI/server/DongfangHydro.Dashboard.Api/Services/ProgressUpdateService.cs
T

253 lines
9.4 KiB
C#

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