admin/src/Oms.Application/Services/Orders/OrderService.Exception.cs
zzh 90f6c25b04 feat(order): 问题件/理赔件标记、通知投递与自动触发
新增 mark-problematic/mark-claimed/handle-status 接口,异常事件审计表与占位符渲染;inApp/email 通知及下单/入库/出库自动触发运行时。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 16:46:52 +08:00

387 lines
13 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.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,
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 =>
{
if (!OrderExceptionStatusRules.CanMarkException(order.OrderStatus))
{
return OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus);
}
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 = payload.ClaimCurrency;
order.ClaimReason = payload.ClaimReason;
order.ClaimTime = DateTime.Now;
order.IssueType = null;
order.IssueTitle = null;
order.IssueContent = null;
order.IssueTemplateId = null;
order.IssueRemark = null;
order.IssueTime = null;
return null;
},
(order, remark) => new OrderExceptionEvent
{
OrderId = order.Id,
EventType = OrderExceptionEventTypeCodes.MarkClaimed,
FromStatus = order.PreviousOrderStatus,
ToStatus = OrderStatusCodes.Claimed,
FromHandleStatus = null,
ToHandleStatus = OrderHandleStatusCodes.Pending,
ClaimType = payload.ClaimType,
ClaimAmount = payload.ClaimAmount,
ClaimCurrency = payload.ClaimCurrency,
ClaimReason = payload.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());
}
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");
}
return new ResolvedProblematicPayload
{
IssueType = issueType,
Title = title,
Content = content,
IssueTemplateId = template?.Id,
NotifyMethod = template == null ? null : IssueNotifyMethodCodes.Normalize(template.NotifyMethod),
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))
{
throw new BusinessException("索赔币种不能为空");
}
if (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 = currency,
ClaimReason = reason,
Remark = remark
};
}
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? Remark { get; set; }
}
private sealed class ResolvedClaimedPayload
{
public string ClaimType { get; set; } = string.Empty;
public decimal ClaimAmount { get; set; }
public string ClaimCurrency { get; set; } = string.Empty;
public string ClaimReason { get; set; } = string.Empty;
public string? Remark { get; set; }
}
}