补齐揽收单字段/并发戳与问题件处理人配置,扩展异常工作流与下单同步揽收出参;移除已收货状态并补充相关 SQL 脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
577 lines
20 KiB
C#
577 lines
20 KiB
C#
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; }
|
||
}
|
||
}
|