253 lines
9.4 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|