admin/src/Oms.Application/Services/Orders/OrderService.Exception.cs
zzh b2355104cf feat: 完善揽收单与问题件处理能力
补齐揽收单字段/并发戳与问题件处理人配置,扩展异常工作流与下单同步揽收出参;移除已收货状态并补充相关 SQL 脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:49:23 +08:00

577 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Fake.Application;
using Fake.Domain.Exceptions;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders.Issues;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Services.Orders;
public partial class OrderService
{
private const int MaxExceptionBatchSize = 200;
private IIssueTemplateRepository IssueTemplateRepository =>
LazyServiceProvider.GetRequiredService<IIssueTemplateRepository>();
private IOrderExceptionEventRepository OrderExceptionEventRepository =>
LazyServiceProvider.GetRequiredService<IOrderExceptionEventRepository>();
private OrderExceptionWorkflowService ExceptionWorkflowService =>
LazyServiceProvider.GetRequiredService<OrderExceptionWorkflowService>();
/// <summary>
/// 批量创建问题件。
/// </summary>
[HttpPost("/order/mark-problematic")]
public async Task<OrderBatchActionResultOutput> MarkProblematicAsync([FromBody] OrderMarkProblematicInput input)
{
var payload = await ResolveProblematicPayloadAsync(input);
var request = new ProblematicMarkRequest
{
IssueType = payload.IssueType,
Title = payload.Title,
Content = payload.Content,
IssueTemplateId = payload.IssueTemplateId,
NotifyMethod = payload.NotifyMethod,
HandlerNames = payload.HandlerNames,
Remark = payload.Remark
};
return await ExecuteProblematicBatchAsync(input.OrderIds, request);
}
/// <summary>
/// 批量创建理赔件。
/// </summary>
[HttpPost("/order/mark-claimed")]
public async Task<OrderBatchActionResultOutput> MarkClaimedAsync([FromBody] OrderMarkClaimedInput input)
{
var payload = NormalizeClaimedPayload(input);
return await ExecuteExceptionBatchAsync(
input.OrderIds,
order => TryApplyClaimed(order, payload),
(order, remark) => new OrderExceptionEvent
{
OrderId = order.Id,
EventType = OrderExceptionEventTypeCodes.MarkClaimed,
FromStatus = order.PreviousOrderStatus,
ToStatus = OrderStatusCodes.Claimed,
FromHandleStatus = null,
ToHandleStatus = OrderHandleStatusCodes.Pending,
ClaimType = order.ClaimType,
ClaimAmount = order.ClaimAmount,
ClaimCurrency = order.ClaimCurrency,
ClaimReason = order.ClaimReason,
Remark = remark
},
payload.Remark);
}
/// <summary>
/// 批量更新处理子状态。
/// </summary>
[HttpPost("/order/handle-status/update")]
public async Task<OrderBatchActionResultOutput> UpdateHandleStatusAsync([FromBody] OrderHandleStatusUpdateInput input)
{
var targetStatus = input.HandleStatus?.Trim().ToLowerInvariant();
if (!string.Equals(targetStatus, OrderHandleStatusCodes.Processed, StringComparison.Ordinal))
{
throw new BusinessException("仅支持标记为已处理");
}
return await ExecuteExceptionBatchAsync(
input.OrderIds,
order =>
{
if (order.OrderStatus is not (OrderStatusCodes.Problematic or OrderStatusCodes.Claimed))
{
return "仅问题件或理赔件可更新处理状态";
}
if (OrderHandleStatusCodes.IsProcessed(order.HandleStatus))
{
return "订单已处理";
}
order.HandleStatus = OrderHandleStatusCodes.Processed;
return null;
},
(order, remark) => new OrderExceptionEvent
{
OrderId = order.Id,
EventType = OrderExceptionEventTypeCodes.HandleProcessed,
FromStatus = order.OrderStatus,
ToStatus = order.OrderStatus,
FromHandleStatus = OrderHandleStatusCodes.Pending,
ToHandleStatus = OrderHandleStatusCodes.Processed,
Remark = remark
},
input.Remark?.Trim());
}
/// <summary>
/// 订单问题处理记录列表(mark_problematic 事件,支持一单多条)。
/// </summary>
[HttpGet("/order/exception-events/list")]
public async Task<OrderExceptionEventListOutput> GetExceptionEventListAsync([FromQuery] int orderId)
{
if (orderId <= 0)
{
throw new BusinessException("订单Id无效");
}
await GetOrderOrThrowAsync(orderId);
var events = await OrderExceptionEventRepository.GetListAsync(x =>
x.OrderId == orderId && x.EventType == OrderExceptionEventTypeCodes.MarkProblematic);
events = events.OrderByDescending(x => x.CreateTime).ThenByDescending(x => x.Id).ToList();
var userIds = events
.SelectMany(x => new int?[] { x.CreateUserId, x.UpdateUserId, x.HandlerUserId })
.Where(x => x is > 0)
.Select(x => x!.Value)
.Distinct();
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(userIds);
var items = events.Select(evt => MapExceptionEventOutput(evt, auditUserNameMap)).ToList();
return new OrderExceptionEventListOutput
{
Items = items,
PendingCount = items.Count(x => OrderHandleStatusCodes.IsPending(x.HandleStatus)),
ProcessedCount = items.Count(x => OrderHandleStatusCodes.IsProcessed(x.HandleStatus))
};
}
/// <summary>
/// 单条问题标记已处理。
/// </summary>
[HttpPost("/order/exception-event/process")]
public async Task ProcessExceptionEventAsync([FromBody] OrderExceptionEventProcessInput input)
{
if (input.Id <= 0)
{
throw new BusinessException("问题记录Id无效");
}
var evt = await OrderExceptionEventRepository.FirstOrDefaultAsync(x => x.Id == input.Id);
if (evt == null || !string.Equals(evt.EventType, OrderExceptionEventTypeCodes.MarkProblematic, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("问题记录不存在");
}
await GetOrderOrThrowAsync(evt.OrderId);
if (OrderHandleStatusCodes.IsProcessed(evt.ToHandleStatus))
{
throw new BusinessException("该问题已处理");
}
var remark = input.Remark?.Trim();
if (!string.IsNullOrWhiteSpace(remark) && remark.Length > 512)
{
throw new BusinessException("备注长度不能超过512");
}
var operatorUserId = CurrentUser.GetId<int>();
evt.ToHandleStatus = OrderHandleStatusCodes.Processed;
evt.UpdateUserId = operatorUserId;
evt.UpdateTime = DateTime.Now;
if (!string.IsNullOrWhiteSpace(remark))
{
evt.Remark = remark;
}
await OrderExceptionEventRepository.UpdateAsync(evt);
await OrderExceptionEventRepository.InsertAsync(new OrderExceptionEvent
{
OrderId = evt.OrderId,
EventType = OrderExceptionEventTypeCodes.HandleProcessed,
FromStatus = OrderStatusCodes.Problematic,
ToStatus = OrderStatusCodes.Problematic,
FromHandleStatus = OrderHandleStatusCodes.Pending,
ToHandleStatus = OrderHandleStatusCodes.Processed,
IssueType = evt.IssueType,
Title = evt.Title,
Content = evt.Content,
Remark = remark
});
await SyncOrderHandleStatusFromEventsAsync(evt.OrderId);
}
private async Task SyncOrderHandleStatusFromEventsAsync(int orderId)
{
var order = await GetOrderOrThrowAsync(orderId);
if (order.OrderStatus is not (OrderStatusCodes.Problematic or OrderStatusCodes.Claimed))
{
return;
}
var issues = await OrderExceptionEventRepository.GetListAsync(x =>
x.OrderId == orderId && x.EventType == OrderExceptionEventTypeCodes.MarkProblematic);
if (issues.Count == 0)
{
return;
}
var anyPending = issues.Any(x => OrderHandleStatusCodes.IsPending(x.ToHandleStatus));
order.HandleStatus = anyPending ? OrderHandleStatusCodes.Pending : OrderHandleStatusCodes.Processed;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
private static OrderExceptionEventOutput MapExceptionEventOutput(
OrderExceptionEvent evt,
IReadOnlyDictionary<int, string> auditUserNameMap)
{
var notifyCodes = IssueNotifyMethodCodes.ParseList(evt.NotifyMethod);
var notifyNames = notifyCodes.Count == 0
? string.Empty
: string.Join('、', notifyCodes.Select(IssueNotifyMethodCodes.GetDisplayName));
return new OrderExceptionEventOutput
{
Id = evt.Id,
OrderId = evt.OrderId,
EventType = evt.EventType,
IssueType = evt.IssueType,
IssueTypeName = IssueTypeCodes.GetDisplayName(evt.IssueType),
Title = evt.Title,
Content = evt.Content,
NotifyMethod = evt.NotifyMethod,
NotifyMethodNames = notifyNames,
HandlerNames = evt.HandlerNames
?? (evt.HandlerUserId is int handlerUserId && handlerUserId > 0
? OmsRbacUserDisplayNameResolver.FormatUserId(handlerUserId, auditUserNameMap)
: null),
HandlerType = evt.HandlerType,
HandlerTypeName = IssueHandlerTypeCodes.GetDisplayName(evt.HandlerType),
HandlerUserId = evt.HandlerUserId,
HandleStatus = evt.ToHandleStatus,
HandleStatusName = OrderHandleStatusCodes.GetDisplayName(evt.ToHandleStatus),
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(evt.CreateUserId, auditUserNameMap),
CreateTime = evt.CreateTime,
ProcessedBy = OrderHandleStatusCodes.IsProcessed(evt.ToHandleStatus)
? OmsRbacUserDisplayNameResolver.FormatUserId(evt.UpdateUserId, auditUserNameMap)
: null,
ProcessedTime = OrderHandleStatusCodes.IsProcessed(evt.ToHandleStatus) ? evt.UpdateTime : null,
Remark = evt.Remark
};
}
private async Task<OrderBatchActionResultOutput> ExecuteProblematicBatchAsync(
IReadOnlyCollection<int> orderIds,
ProblematicMarkRequest request)
{
var ids = NormalizeOrderIds(orderIds);
var result = new OrderBatchActionResultOutput();
var operatorUserId = CurrentUser.GetId<int>();
var sendNotification = !string.IsNullOrWhiteSpace(request.NotifyMethod);
foreach (var orderId in ids)
{
Order? order;
try
{
order = await GetOrderOrThrowAsync(orderId);
}
catch (BusinessException ex)
{
result.Failures.Add(new OrderBatchActionFailureOutput
{
OrderId = orderId,
Reason = ex.Message
});
continue;
}
var reason = await ExceptionWorkflowService.TryMarkProblematicAsync(
order,
request,
operatorUserId,
sendNotification);
if (!string.IsNullOrWhiteSpace(reason))
{
result.Failures.Add(new OrderBatchActionFailureOutput
{
OrderId = order.Id,
ServiceNo = order.ServiceNo,
Reason = reason
});
continue;
}
result.SuccessCount++;
}
return result;
}
private async Task<OrderBatchActionResultOutput> ExecuteExceptionBatchAsync(
IReadOnlyCollection<int> orderIds,
Func<Order, string?> tryApply,
Func<Order, string?, OrderExceptionEvent> buildEvent,
string? remark)
{
var ids = NormalizeOrderIds(orderIds);
var result = new OrderBatchActionResultOutput();
foreach (var orderId in ids)
{
Order? order;
try
{
order = await GetOrderOrThrowAsync(orderId);
}
catch (BusinessException ex)
{
result.Failures.Add(new OrderBatchActionFailureOutput
{
OrderId = orderId,
Reason = ex.Message
});
continue;
}
var reason = tryApply(order);
if (!string.IsNullOrWhiteSpace(reason))
{
result.Failures.Add(new OrderBatchActionFailureOutput
{
OrderId = order.Id,
ServiceNo = order.ServiceNo,
Reason = reason
});
continue;
}
await OrderRepository.UpdateAsync(order);
await OrderExceptionEventRepository.InsertAsync(buildEvent(order, remark));
result.SuccessCount++;
}
return result;
}
private static List<int> NormalizeOrderIds(IReadOnlyCollection<int>? orderIds)
{
if (orderIds == null || orderIds.Count == 0)
{
throw new BusinessException("请至少选择一条订单");
}
var ids = orderIds.Where(x => x > 0).Distinct().ToList();
if (ids.Count == 0)
{
throw new BusinessException("请至少选择一条订单");
}
if (ids.Count > MaxExceptionBatchSize)
{
throw new BusinessException($"单次最多处理 {MaxExceptionBatchSize} 条订单");
}
return ids;
}
private async Task<ResolvedProblematicPayload> ResolveProblematicPayloadAsync(OrderMarkProblematicInput input)
{
IssueTemplate? template = null;
if (input.IssueTemplateId is int templateId && templateId > 0)
{
template = await IssueTemplateRepository.FirstOrDefaultAsync(x =>
x.Id == templateId && x.Status == EnableStatus.Enabled);
if (template == null)
{
throw new BusinessException("问题模板不存在或未启用");
}
}
var issueType = (template?.IssueType ?? input.IssueType)?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(issueType) || !IssueTypeCodes.All.Contains(issueType))
{
throw new BusinessException("问题类型不合法");
}
var title = (input.Title?.Trim().Length > 0 ? input.Title.Trim() : template?.Title)?.Trim();
if (string.IsNullOrWhiteSpace(title))
{
throw new BusinessException("问题标题不能为空");
}
if (title.Length > 128)
{
throw new BusinessException("问题标题长度不能超过128");
}
var content = (input.Content?.Trim().Length > 0 ? input.Content.Trim() : template?.Content)?.Trim();
if (string.IsNullOrWhiteSpace(content))
{
throw new BusinessException("问题内容不能为空");
}
var remark = input.Remark?.Trim();
if (!string.IsNullOrWhiteSpace(remark) && remark.Length > 512)
{
throw new BusinessException("备注长度不能超过512");
}
var handlerNames = input.HandlerNames?.Trim();
if (!string.IsNullOrWhiteSpace(handlerNames) && handlerNames.Length > 256)
{
throw new BusinessException("处理人长度不能超过256");
}
return new ResolvedProblematicPayload
{
IssueType = issueType,
Title = title,
Content = content,
IssueTemplateId = template?.Id,
NotifyMethod = template == null ? null : IssueNotifyMethodCodes.Normalize(template.NotifyMethod),
HandlerNames = string.IsNullOrWhiteSpace(handlerNames) ? null : handlerNames,
Remark = remark
};
}
private static ResolvedClaimedPayload NormalizeClaimedPayload(OrderMarkClaimedInput input)
{
var claimType = input.ClaimType?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(claimType) || !ClaimTypeCodes.All.Contains(claimType))
{
throw new BusinessException("理赔类型不合法");
}
if (input.ClaimAmount is not > 0)
{
throw new BusinessException("索赔金额必须大于0");
}
var currency = input.ClaimCurrency?.Trim().ToUpperInvariant();
if (!string.IsNullOrWhiteSpace(currency) && currency.Length > 16)
{
throw new BusinessException("索赔币种长度不能超过16");
}
var reason = input.ClaimReason?.Trim();
if (string.IsNullOrWhiteSpace(reason))
{
throw new BusinessException("理赔原因不能为空");
}
if (reason.Length > 512)
{
throw new BusinessException("理赔原因长度不能超过512");
}
var remark = input.Remark?.Trim();
if (!string.IsNullOrWhiteSpace(remark) && remark.Length > 512)
{
throw new BusinessException("备注长度不能超过512");
}
return new ResolvedClaimedPayload
{
ClaimType = claimType,
ClaimAmount = input.ClaimAmount.Value,
ClaimCurrency = string.IsNullOrWhiteSpace(currency) ? null : currency,
ClaimReason = reason,
Remark = remark
};
}
/// <summary>
/// 按订单附加服务/申报金额校验并写入理赔字段。
/// </summary>
private static string? TryApplyClaimed(Order order, ResolvedClaimedPayload payload)
{
if (!OrderExceptionStatusRules.CanMarkException(order.OrderStatus))
{
return OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus);
}
if (order.DeclaredSum is > 0 && payload.ClaimAmount > order.DeclaredSum.Value)
{
return "索赔金额不能超过申报金额";
}
var hasInsurance = ParseAdditionalServiceCodes(order.AdditionalServices)
.Contains("insurance");
var currency = payload.ClaimCurrency;
if (string.IsNullOrWhiteSpace(currency))
{
if (hasInsurance && !string.IsNullOrWhiteSpace(order.DeclaredCurrency))
{
currency = order.DeclaredCurrency.Trim().ToUpperInvariant();
}
else
{
return "索赔币种不能为空";
}
}
var fromStatus = order.OrderStatus;
order.PreviousOrderStatus = fromStatus;
order.OrderStatus = OrderStatusCodes.Claimed;
order.HandleStatus = OrderHandleStatusCodes.Pending;
order.ClaimType = payload.ClaimType;
order.ClaimAmount = payload.ClaimAmount;
order.ClaimCurrency = currency;
order.ClaimReason = payload.ClaimReason;
order.ClaimTime = DateTime.Now;
order.IssueType = null;
order.IssueTitle = null;
order.IssueContent = null;
order.IssueTemplateId = null;
order.IssueTime = null;
if (!hasInsurance)
{
const string uninsuredPrefix = "[未购保险]";
order.IssueRemark = string.IsNullOrWhiteSpace(payload.Remark)
? uninsuredPrefix
: $"{uninsuredPrefix}{payload.Remark}";
}
else
{
order.IssueRemark = null;
}
return null;
}
private sealed class ResolvedProblematicPayload
{
public string IssueType { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public int? IssueTemplateId { get; set; }
public string? NotifyMethod { get; set; }
public string? HandlerNames { get; set; }
public string? Remark { get; set; }
}
private sealed class ResolvedClaimedPayload
{
public string ClaimType { get; set; } = string.Empty;
public decimal ClaimAmount { get; set; }
public string? ClaimCurrency { get; set; }
public string ClaimReason { get; set; } = string.Empty;
public string? Remark { get; set; }
}
}