feat(order): 问题件/理赔件标记、通知投递与自动触发

新增 mark-problematic/mark-claimed/handle-status 接口,异常事件审计表与占位符渲染;inApp/email 通知及下单/入库/出库自动触发运行时。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-06 16:46:52 +08:00
parent 9359c9cb5b
commit 90f6c25b04
33 changed files with 1641 additions and 2 deletions

40
db.sql
View File

@ -75,6 +75,19 @@ CREATE TABLE `t_order` (
`SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本', `SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本',
`SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本', `SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本',
`OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态', `OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态',
`HandleStatus` VARCHAR(16) NULL COMMENT '处理子状态:pending/processed',
`IssueType` VARCHAR(32) NULL COMMENT '问题类型',
`IssueTitle` VARCHAR(128) NULL COMMENT '问题标题',
`IssueContent` TEXT NULL COMMENT '问题内容',
`IssueTemplateId` INT NULL COMMENT '问题模板Id',
`IssueRemark` VARCHAR(512) NULL COMMENT '问题备注',
`IssueTime` DATETIME NULL COMMENT '标记问题件时间',
`ClaimType` VARCHAR(32) NULL COMMENT '理赔类型',
`ClaimAmount` DECIMAL(18,4) NULL COMMENT '索赔金额',
`ClaimCurrency` VARCHAR(16) NULL COMMENT '索赔币种',
`ClaimReason` VARCHAR(512) NULL COMMENT '理赔原因',
`ClaimTime` DATETIME NULL COMMENT '创建理赔件时间',
`PreviousOrderStatus` VARCHAR(32) NULL COMMENT '进入异常状态前的订单状态',
`WarehouseId` INT NULL COMMENT '仓库ID', `WarehouseId` INT NULL COMMENT '仓库ID',
`CustomerId` INT NULL COMMENT '客户ID', `CustomerId` INT NULL COMMENT '客户ID',
`SenderId` INT NULL COMMENT '发件人ID', `SenderId` INT NULL COMMENT '发件人ID',
@ -307,6 +320,33 @@ CREATE TABLE `t_order_fee` (
KEY `idx_ord_fee_price_no` (`PriceNo`) KEY `idx_ord_fee_price_no` (`PriceNo`)
) COMMENT='订单费用表'; ) COMMENT='订单费用表';
CREATE TABLE `t_order_exception_event` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`OrderId` INT NOT NULL COMMENT '订单Id',
`EventType` VARCHAR(32) NOT NULL COMMENT 'mark_problematic/mark_claimed/handle_processed',
`FromStatus` VARCHAR(32) NULL COMMENT '变更前OrderStatus',
`ToStatus` VARCHAR(32) NULL COMMENT '变更后OrderStatus',
`FromHandleStatus` VARCHAR(16) NULL COMMENT '变更前HandleStatus',
`ToHandleStatus` VARCHAR(16) NULL COMMENT '变更后HandleStatus',
`IssueType` VARCHAR(32) NULL COMMENT '问题类型',
`Title` VARCHAR(128) NULL COMMENT '标题快照',
`Content` TEXT NULL COMMENT '内容快照',
`IssueTemplateId` INT NULL COMMENT '模板Id',
`NotifyMethod` VARCHAR(64) NULL COMMENT '通知方式快照',
`ClaimType` VARCHAR(32) NULL COMMENT '理赔类型',
`ClaimAmount` DECIMAL(18,4) NULL COMMENT '索赔金额',
`ClaimCurrency` VARCHAR(16) NULL COMMENT '索赔币种',
`ClaimReason` VARCHAR(512) NULL COMMENT '理赔原因',
`Remark` VARCHAR(512) NULL COMMENT '操作备注',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '操作人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
`UpdateUserId` INT NULL COMMENT '更新人',
`UpdateTime` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`Id`),
KEY `idx_oee_order_time` (`OrderId`, `CreateTime`)
) COMMENT='订单异常事件表';
CREATE TABLE `t_sales_product` ( CREATE TABLE `t_sales_product` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`ProductCode` VARCHAR(64) NOT NULL COMMENT '产品编码', `ProductCode` VARCHAR(64) NOT NULL COMMENT '产品编码',

View File

@ -0,0 +1,27 @@
-- 订单异常事件审计表
CREATE TABLE IF NOT EXISTS `t_order_exception_event` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`OrderId` INT NOT NULL COMMENT '订单Id',
`EventType` VARCHAR(32) NOT NULL COMMENT 'mark_problematic/mark_claimed/handle_processed',
`FromStatus` VARCHAR(32) NULL COMMENT '变更前OrderStatus',
`ToStatus` VARCHAR(32) NULL COMMENT '变更后OrderStatus',
`FromHandleStatus` VARCHAR(16) NULL COMMENT '变更前HandleStatus',
`ToHandleStatus` VARCHAR(16) NULL COMMENT '变更后HandleStatus',
`IssueType` VARCHAR(32) NULL COMMENT '问题类型',
`Title` VARCHAR(128) NULL COMMENT '标题快照',
`Content` TEXT NULL COMMENT '内容快照',
`IssueTemplateId` INT NULL COMMENT '模板Id',
`NotifyMethod` VARCHAR(64) NULL COMMENT '通知方式快照',
`ClaimType` VARCHAR(32) NULL COMMENT '理赔类型',
`ClaimAmount` DECIMAL(18,4) NULL COMMENT '索赔金额',
`ClaimCurrency` VARCHAR(16) NULL COMMENT '索赔币种',
`ClaimReason` VARCHAR(512) NULL COMMENT '理赔原因',
`Remark` VARCHAR(512) NULL COMMENT '操作备注',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '操作人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
`UpdateUserId` INT NULL COMMENT '更新人',
`UpdateTime` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`Id`),
KEY `idx_oee_order_time` (`OrderId`, `CreateTime`)
) COMMENT='订单异常事件表';

View File

@ -0,0 +1,14 @@
-- 问题件 / 理赔件快照字段
ALTER TABLE `t_order`
ADD COLUMN `IssueType` VARCHAR(32) NULL COMMENT '问题类型' AFTER `HandleStatus`,
ADD COLUMN `IssueTitle` VARCHAR(128) NULL COMMENT '问题标题' AFTER `IssueType`,
ADD COLUMN `IssueContent` TEXT NULL COMMENT '问题内容' AFTER `IssueTitle`,
ADD COLUMN `IssueTemplateId` INT NULL COMMENT '问题模板Id' AFTER `IssueContent`,
ADD COLUMN `IssueRemark` VARCHAR(512) NULL COMMENT '问题备注' AFTER `IssueTemplateId`,
ADD COLUMN `IssueTime` DATETIME NULL COMMENT '标记问题件时间' AFTER `IssueRemark`,
ADD COLUMN `ClaimType` VARCHAR(32) NULL COMMENT '理赔类型' AFTER `IssueTime`,
ADD COLUMN `ClaimAmount` DECIMAL(18,4) NULL COMMENT '索赔金额' AFTER `ClaimType`,
ADD COLUMN `ClaimCurrency` VARCHAR(16) NULL COMMENT '索赔币种' AFTER `ClaimAmount`,
ADD COLUMN `ClaimReason` VARCHAR(512) NULL COMMENT '理赔原因' AFTER `ClaimCurrency`,
ADD COLUMN `ClaimTime` DATETIME NULL COMMENT '创建理赔件时间' AFTER `ClaimReason`,
ADD COLUMN `PreviousOrderStatus` VARCHAR(32) NULL COMMENT '进入异常状态前的订单状态' AFTER `ClaimTime`;

View File

@ -0,0 +1,12 @@
-- 问题件 / 已理赔 子状态:pending-待处理 processed-已处理
ALTER TABLE `t_order`
ADD COLUMN `HandleStatus` VARCHAR(16) NULL DEFAULT 'pending' COMMENT '处理子状态:pending/processed' AFTER `OrderStatus`;
UPDATE `t_order`
SET `HandleStatus` = 'pending'
WHERE `HandleStatus` IS NULL
AND `OrderStatus` IN ('problematic', 'claimed');
UPDATE `t_order`
SET `HandleStatus` = NULL
WHERE `OrderStatus` NOT IN ('problematic', 'claimed');

View File

@ -0,0 +1,56 @@
namespace Oms.Application.Dtos.Orders;
public class OrderBatchActionFailureOutput
{
public int OrderId { get; set; }
public string? ServiceNo { get; set; }
public string Reason { get; set; } = string.Empty;
}
public class OrderBatchActionResultOutput
{
public int SuccessCount { get; set; }
public List<OrderBatchActionFailureOutput> Failures { get; set; } = [];
}
public class OrderMarkProblematicInput
{
public List<int> OrderIds { get; set; } = [];
public int? IssueTemplateId { get; set; }
public string? IssueType { get; set; }
public string? Title { get; set; }
public string? Content { get; set; }
public string? Remark { get; set; }
}
public class OrderMarkClaimedInput
{
public List<int> OrderIds { get; set; } = [];
public string? ClaimType { get; set; }
public decimal? ClaimAmount { get; set; }
public string? ClaimCurrency { get; set; }
public string? ClaimReason { get; set; }
public string? Remark { get; set; }
}
public class OrderHandleStatusUpdateInput
{
public List<int> OrderIds { get; set; } = [];
public string? HandleStatus { get; set; }
public string? Remark { get; set; }
}

View File

@ -0,0 +1,13 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 问题件/已理赔子状态数量。
/// </summary>
public class OrderHandleStatusCountsOutput
{
public int Pending { get; set; }
public int Processed { get; set; }
public int Total => Pending + Processed;
}

View File

@ -145,6 +145,44 @@ public class OrderOutput
/// </summary> /// </summary>
public string Status { get; set; } = string.Empty; public string Status { get; set; } = string.Empty;
/// <summary>
/// 子状态 code:pending / processed。
/// </summary>
public string? HandleStatus { get; set; }
/// <summary>
/// 子状态名称。
/// </summary>
public string? HandleStatusName { get; set; }
public string? IssueType { get; set; }
public string? IssueTypeName { get; set; }
public string? IssueTitle { get; set; }
public string? IssueContent { get; set; }
public int? IssueTemplateId { get; set; }
public string? IssueRemark { get; set; }
public DateTime? IssueTime { get; set; }
public string? ClaimType { get; set; }
public string? ClaimTypeName { get; set; }
public decimal? ClaimAmount { get; set; }
public string? ClaimCurrency { get; set; }
public string? ClaimReason { get; set; }
public DateTime? ClaimTime { get; set; }
public string? PreviousOrderStatus { get; set; }
/// <summary> /// <summary>
/// 收货时间。 /// 收货时间。
/// </summary> /// </summary>

View File

@ -24,6 +24,13 @@ public class OrderPaginatedInput
[global::System.Text.Json.Serialization.JsonPropertyName("status")] [global::System.Text.Json.Serialization.JsonPropertyName("status")]
public string? Status { get; set; } public string? Status { get; set; }
/// <summary>
/// 子状态:all / pending / processed(仅问题件、已理赔 Tab 使用)。
/// </summary>
[Newtonsoft.Json.JsonProperty("handleStatus")]
[global::System.Text.Json.Serialization.JsonPropertyName("handleStatus")]
public string? HandleStatus { get; set; }
/// <summary> /// <summary>
/// 关键词。 /// 关键词。
/// </summary> /// </summary>

View File

@ -14,4 +14,10 @@ public class OrderStatusCountsOutput
/// 按 OrderStatus 分组的数量,key 为状态码。 /// 按 OrderStatus 分组的数量,key 为状态码。
/// </summary> /// </summary>
public Dictionary<string, int> Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, int> Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 问题件/已理赔子状态数量,key 为 OrderStatus(problematic/claimed)。
/// </summary>
public Dictionary<string, OrderHandleStatusCountsOutput> HandleCounts { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
} }

View File

@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Operations; using Oms.Application.Dtos.Operations;
using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Shared;
using Oms.Application.Services.Orders; using Oms.Application.Services.Orders;
using Oms.Application.Services.Orders.Issues;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OperationsAggregate.Entities; using Oms.Domain.OperationsAggregate.Entities;
@ -24,6 +25,8 @@ public class InboundRecordService : ApplicationService
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>(); private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>(); private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>(); private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>();
private AutoTriggerRuntimeService AutoTriggerRuntimeService =>
LazyServiceProvider.GetRequiredService<AutoTriggerRuntimeService>();
/// <summary> /// <summary>
/// 按单号查询订单(入库扫描展示销售产品、目的国,只读)。 /// 按单号查询订单(入库扫描展示销售产品、目的国,只读)。
@ -130,6 +133,20 @@ public class InboundRecordService : ApplicationService
input.Weight); input.Weight);
var savedEntity = await InboundRecordRepository.InsertAsync(entity); var savedEntity = await InboundRecordRepository.InsertAsync(entity);
try
{
var refreshed = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim());
if (refreshed != null)
{
await AutoTriggerRuntimeService.FireAsync(refreshed, AutoTriggerTiming.Inbound, operatorId);
}
}
catch
{
// 自动触发失败不影响入库主流程
}
return ObjectMapper.Map<InboundRecord, InboundRecordOutput>(savedEntity); return ObjectMapper.Map<InboundRecord, InboundRecordOutput>(savedEntity);
} }

View File

@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Operations; using Oms.Application.Dtos.Operations;
using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Shared;
using Oms.Application.Services.Orders; using Oms.Application.Services.Orders;
using Oms.Application.Services.Orders.Issues;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OperationsAggregate.Entities; using Oms.Domain.OperationsAggregate.Entities;
@ -28,6 +29,8 @@ public class OutboundRecordService : ApplicationService
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>(); private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>(); private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>(); private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>();
private AutoTriggerRuntimeService AutoTriggerRuntimeService =>
LazyServiceProvider.GetRequiredService<AutoTriggerRuntimeService>();
/// <summary> /// <summary>
/// 按单号查询订单(出库扫描展示销售产品、目的国、服务渠道,只读)。 /// 按单号查询订单(出库扫描展示销售产品、目的国、服务渠道,只读)。
@ -157,6 +160,20 @@ public class OutboundRecordService : ApplicationService
input.Weight); input.Weight);
var savedEntity = await OutboundRecordRepository.InsertAsync(entity); var savedEntity = await OutboundRecordRepository.InsertAsync(entity);
try
{
var refreshed = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim());
if (refreshed != null)
{
await AutoTriggerRuntimeService.FireAsync(refreshed, AutoTriggerTiming.Outbound, operatorId);
}
}
catch
{
// 自动触发失败不影响出库主流程
}
return ObjectMapper.Map<OutboundRecord, OutboundRecordOutput>(savedEntity); return ObjectMapper.Map<OutboundRecord, OutboundRecordOutput>(savedEntity);
} }

View File

@ -0,0 +1,153 @@
using System.Text.Json;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders.Issues;
public static class AutoTriggerConditionMatcher
{
public static bool Matches(Order order, string? triggerCondition)
{
var text = triggerCondition?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
return true;
}
if (text.StartsWith('{') && text.EndsWith('}'))
{
return MatchesJson(order, text);
}
var lowered = text.ToLowerInvariant();
if (IssueTypeCodes.All.Contains(lowered))
{
return true;
}
return ContainsKeyword(order, text);
}
public static string ResolveIssueType(string? triggerCondition)
{
var text = triggerCondition?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
return IssueTypeCodes.Other;
}
if (text.StartsWith('{') && text.EndsWith('}'))
{
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.TryGetProperty("issueType", out var issueTypeElement))
{
var code = issueTypeElement.GetString()?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(code) && IssueTypeCodes.All.Contains(code))
{
return code;
}
}
}
catch (JsonException)
{
// fall through
}
}
var lowered = text.ToLowerInvariant();
if (IssueTypeCodes.All.Contains(lowered))
{
return lowered;
}
return IssueTypeCodes.Other;
}
public static List<int> ParseNotifierUserIds(string? notifier)
{
if (string.IsNullOrWhiteSpace(notifier))
{
return [];
}
return notifier
.Split([',', ';', ' ', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => int.TryParse(x, out var id) ? id : 0)
.Where(x => x > 0)
.Distinct()
.ToList();
}
private static bool MatchesJson(Order order, string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("orderStatus", out var statusElement))
{
var expected = statusElement.GetString()?.Trim();
if (!string.IsNullOrWhiteSpace(expected)
&& !string.Equals(order.OrderStatus, expected, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
if (root.TryGetProperty("productCode", out var productElement))
{
var expected = productElement.GetString()?.Trim();
if (!string.IsNullOrWhiteSpace(expected)
&& !string.Equals(order.ProductCode?.Trim(), expected, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
if (root.TryGetProperty("channelCode", out var channelElement))
{
var expected = channelElement.GetString()?.Trim();
if (!string.IsNullOrWhiteSpace(expected)
&& !string.Equals(order.ChannelCode?.Trim(), expected, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
if (root.TryGetProperty("keyword", out var keywordElement))
{
var keyword = keywordElement.GetString()?.Trim();
if (!string.IsNullOrWhiteSpace(keyword) && !ContainsKeyword(order, keyword))
{
return false;
}
}
return true;
}
catch (JsonException)
{
return ContainsKeyword(order, json);
}
}
private static bool ContainsKeyword(Order order, string keyword)
{
if (string.IsNullOrWhiteSpace(keyword))
{
return true;
}
return Contains(order.ServiceNo, keyword)
|| Contains(order.ClientNo, keyword)
|| Contains(order.TrackNo, keyword)
|| Contains(order.ProductCode, keyword)
|| Contains(order.ChannelCode, keyword);
}
private static bool Contains(string? source, string keyword) =>
!string.IsNullOrWhiteSpace(source)
&& source.Contains(keyword, StringComparison.OrdinalIgnoreCase);
}

View File

@ -0,0 +1,129 @@
using Fake.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Services.Orders.Issues;
public class AutoTriggerRuntimeService(
IAutoTriggerRepository autoTriggerRepository,
IOrderRepository orderRepository,
OrderExceptionWorkflowService exceptionWorkflowService,
OrderIssueNotificationService issueNotificationService,
ILogger<AutoTriggerRuntimeService> logger) : IScopedDependency
{
public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId)
{
if (order.Id <= 0)
{
return;
}
var triggers = await autoTriggerRepository.GetListAsync(x => x.Status == EnableStatus.Enabled);
foreach (var trigger in triggers)
{
if (!AutoTriggerTimingCodes.Matches(trigger.TriggerTiming, timing))
{
continue;
}
if (!AutoTriggerConditionMatcher.Matches(order, trigger.TriggerCondition))
{
continue;
}
try
{
await ExecuteTriggerAsync(order, trigger, operatorUserId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "自动触发失败 OrderId={OrderId} TriggerId={TriggerId}", order.Id, trigger.Id);
}
}
}
public async Task FireByOrderIdAsync(int orderId, AutoTriggerTiming timing, int operatorUserId)
{
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
if (order == null)
{
return;
}
await FireAsync(order, timing, operatorUserId);
}
private async Task ExecuteTriggerAsync(
Order order,
AutoTrigger trigger,
int operatorUserId)
{
var operation = trigger.Operation?.Trim();
if (string.Equals(operation, "拦截", StringComparison.Ordinal))
{
if (order.IsIntercept == 0)
{
order.IsIntercept = 1;
await orderRepository.UpdateAsync(order);
}
return;
}
if (string.Equals(operation, "警告", StringComparison.Ordinal))
{
var title = string.IsNullOrWhiteSpace(trigger.PromptMessage)
? "订单预警"
: trigger.PromptMessage.Trim();
var content = string.IsNullOrWhiteSpace(trigger.TriggerCondition)
? $"运单 {{serviceNo}} 触发自动警告规则"
: trigger.TriggerCondition.Trim();
var context = IssuePlaceholderContext.FromOrder(order, null, title);
await issueNotificationService.DispatchAsync(
order,
IssuePlaceholderRenderer.Render(title, context),
IssuePlaceholderRenderer.Render(content, context),
IssueNotifyMethodCodes.InApp,
operatorUserId,
AutoTriggerConditionMatcher.ParseNotifierUserIds(trigger.Notifier));
return;
}
if (!string.Equals(operation, "问题件", StringComparison.Ordinal))
{
return;
}
var issueType = AutoTriggerConditionMatcher.ResolveIssueType(trigger.TriggerCondition);
var request = await exceptionWorkflowService.BuildRequestFromTemplateAsync(
order.OrganizationId,
issueType,
trigger.PromptMessage,
null,
$"自动触发#{trigger.Id}");
if (request == null)
{
logger.LogInformation(
"自动触发问题件跳过:未匹配问题模板 OrderId={OrderId} TriggerId={TriggerId}",
order.Id,
trigger.Id);
return;
}
request.ExtraReceiverUserIds = AutoTriggerConditionMatcher.ParseNotifierUserIds(trigger.Notifier);
var reason = await exceptionWorkflowService.TryMarkProblematicAsync(order, request, operatorUserId, true);
if (!string.IsNullOrWhiteSpace(reason))
{
logger.LogInformation(
"自动触发问题件未执行 OrderId={OrderId} TriggerId={TriggerId} Reason={Reason}",
order.Id,
trigger.Id,
reason);
}
}
}

View File

@ -0,0 +1,45 @@
namespace Oms.Application.Services.Orders.Issues;
public enum AutoTriggerTiming
{
OrderCreate,
Inbound,
Outbound
}
public static class AutoTriggerTimingCodes
{
public const string OrderCreate = "order_create";
public const string Inbound = "inbound";
public const string Outbound = "outbound";
public static bool Matches(string? triggerTiming, AutoTriggerTiming timing)
{
if (string.IsNullOrWhiteSpace(triggerTiming))
{
return false;
}
var text = triggerTiming.Trim();
return timing switch
{
AutoTriggerTiming.OrderCreate => ContainsAny(text, "下单", "order", "create", "parcel", "预报"),
AutoTriggerTiming.Inbound => ContainsAny(text, "入库", "inbound", "receive", "收货"),
AutoTriggerTiming.Outbound => ContainsAny(text, "出库", "outbound", "ship", "发货"),
_ => false
};
}
private static bool ContainsAny(string text, params string[] keywords)
{
foreach (var keyword in keywords)
{
if (text.Contains(keyword, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,80 @@
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders.Issues;
public sealed class IssuePlaceholderContext
{
public string? ServiceNo { get; init; }
public string? ClientNo { get; init; }
public string? TrackNo { get; init; }
public string? ProductCode { get; init; }
public string? ChannelCode { get; init; }
public string? IssueType { get; init; }
public string? IssueTypeName { get; init; }
public string? IssueTitle { get; init; }
public static IssuePlaceholderContext FromOrder(Order order, string? issueType = null, string? issueTitle = null) =>
new()
{
ServiceNo = order.ServiceNo,
ClientNo = order.ClientNo,
TrackNo = order.TrackNo,
ProductCode = order.ProductCode,
ChannelCode = order.ChannelCode,
IssueType = issueType,
IssueTypeName = IssueTypeCodes.GetDisplayName(issueType),
IssueTitle = issueTitle
};
}
public static class IssuePlaceholderRenderer
{
private static readonly (string Key, Func<IssuePlaceholderContext, string?> Resolver)[] Placeholders =
[
("serviceNo", ctx => ctx.ServiceNo),
("clientNo", ctx => ctx.ClientNo),
("trackNo", ctx => ctx.TrackNo),
("productCode", ctx => ctx.ProductCode),
("channelCode", ctx => ctx.ChannelCode),
("issueType", ctx => ctx.IssueType),
("issueTypeName", ctx => ctx.IssueTypeName),
("issueTitle", ctx => ctx.IssueTitle),
("运单号", ctx => ctx.ServiceNo),
("客户单号", ctx => ctx.ClientNo),
("跟踪号", ctx => ctx.TrackNo),
("产品编码", ctx => ctx.ProductCode),
("渠道编码", ctx => ctx.ChannelCode),
("问题类型", ctx => ctx.IssueTypeName ?? ctx.IssueType),
("问题标题", ctx => ctx.IssueTitle),
];
public static string Render(string? template, IssuePlaceholderContext context)
{
if (string.IsNullOrWhiteSpace(template))
{
return string.Empty;
}
var result = template;
foreach (var (key, resolver) in Placeholders)
{
var token = "{" + key + "}";
if (!result.Contains(token, StringComparison.Ordinal))
{
continue;
}
result = result.Replace(token, resolver(context) ?? string.Empty, StringComparison.Ordinal);
}
return result;
}
}

View File

@ -0,0 +1,154 @@
using Fake.DependencyInjection;
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.Issues;
public sealed class ProblematicMarkRequest
{
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; }
public IEnumerable<int>? ExtraReceiverUserIds { get; set; }
}
public class OrderExceptionWorkflowService(
IOrderRepository orderRepository,
IOrderExceptionEventRepository orderExceptionEventRepository,
IIssueTemplateRepository issueTemplateRepository,
OrderIssueNotificationService issueNotificationService) : IScopedDependency
{
public async Task<string?> TryMarkProblematicAsync(
Order order,
ProblematicMarkRequest request,
int operatorUserId,
bool sendNotification)
{
if (!OrderExceptionStatusRules.CanMarkException(order.OrderStatus))
{
return OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus);
}
var context = IssuePlaceholderContext.FromOrder(order, request.IssueType, request.Title);
var renderedTitle = IssuePlaceholderRenderer.Render(request.Title, context);
var renderedContent = IssuePlaceholderRenderer.Render(request.Content, context);
var fromStatus = order.OrderStatus;
order.PreviousOrderStatus = fromStatus;
order.OrderStatus = OrderStatusCodes.Problematic;
order.HandleStatus = OrderHandleStatusCodes.Pending;
order.IssueType = request.IssueType;
order.IssueTitle = renderedTitle;
order.IssueContent = renderedContent;
order.IssueTemplateId = request.IssueTemplateId;
order.IssueRemark = request.Remark;
order.IssueTime = DateTime.Now;
order.ClaimType = null;
order.ClaimAmount = null;
order.ClaimCurrency = null;
order.ClaimReason = null;
order.ClaimTime = null;
await orderRepository.UpdateAsync(order);
await orderExceptionEventRepository.InsertAsync(new OrderExceptionEvent
{
OrderId = order.Id,
EventType = OrderExceptionEventTypeCodes.MarkProblematic,
FromStatus = fromStatus,
ToStatus = OrderStatusCodes.Problematic,
FromHandleStatus = null,
ToHandleStatus = OrderHandleStatusCodes.Pending,
IssueType = request.IssueType,
Title = renderedTitle,
Content = renderedContent,
IssueTemplateId = request.IssueTemplateId,
NotifyMethod = request.NotifyMethod,
Remark = request.Remark
});
if (sendNotification)
{
await issueNotificationService.DispatchAsync(
order,
renderedTitle,
renderedContent,
request.NotifyMethod,
operatorUserId,
request.ExtraReceiverUserIds);
}
return null;
}
public async Task<ProblematicMarkRequest?> BuildRequestFromTemplateAsync(
string? organizationId,
string issueType,
string? titleOverride,
string? contentOverride,
string? remark)
{
var normalizedType = issueType.Trim().ToLowerInvariant();
if (!IssueTypeCodes.All.Contains(normalizedType))
{
normalizedType = IssueTypeCodes.Other;
}
var normalizedOrg = NormalizeOrganizationId(organizationId);
var hasOrgFilter = normalizedOrg != null;
var orgValue = normalizedOrg ?? string.Empty;
var templates = await issueTemplateRepository.GetListAsync(x =>
x.Status == EnableStatus.Enabled
&& x.IssueType == normalizedType
&& (!hasOrgFilter
|| x.OrganizationId == orgValue
|| x.OrganizationId == null
|| x.OrganizationId == string.Empty));
var template = templates.FirstOrDefault(x => x.OrganizationId == normalizedOrg)
?? templates.FirstOrDefault(x => string.IsNullOrWhiteSpace(x.OrganizationId))
?? templates.FirstOrDefault();
var title = !string.IsNullOrWhiteSpace(titleOverride)
? titleOverride.Trim()
: template?.Title?.Trim();
var content = !string.IsNullOrWhiteSpace(contentOverride)
? contentOverride.Trim()
: template?.Content?.Trim();
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(content))
{
return null;
}
return new ProblematicMarkRequest
{
IssueType = normalizedType,
Title = title,
Content = content,
IssueTemplateId = template?.Id,
NotifyMethod = template == null ? null : IssueNotifyMethodCodes.Normalize(template.NotifyMethod),
Remark = remark
};
}
private static string? NormalizeOrganizationId(string? organizationId)
{
var text = organizationId?.Trim();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}

View File

@ -0,0 +1,154 @@
using System.Text.Json;
using Fake.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.System;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
using Oms.Application.Shared;
namespace Oms.Application.Services.Orders.Issues;
public class OrderIssueNotificationService(
INoticeRepository noticeRepository,
ICustomerRepository customerRepository,
ISystemBatchEmailAccountRepository batchEmailAccountRepository,
ILogger<OrderIssueNotificationService> logger) : IScopedDependency
{
private const string NoticeTypeMessage = "message";
private const string ReceiverTypeUser = "user";
public async Task DispatchAsync(
Order order,
string title,
string content,
string? notifyMethodCsv,
int operatorUserId,
IEnumerable<int>? extraReceiverUserIds = null)
{
var methods = IssueNotifyMethodCodes.ParseList(notifyMethodCsv);
if (methods.Count == 0)
{
return;
}
Customer? customer = null;
if (order.CustomerId is int customerId && customerId > 0)
{
customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customerId);
}
foreach (var method in methods)
{
try
{
switch (method)
{
case IssueNotifyMethodCodes.InApp:
await SendInAppAsync(order, customer, title, content, extraReceiverUserIds);
break;
case IssueNotifyMethodCodes.Email:
await SendEmailAsync(order, customer, title, content, operatorUserId);
break;
case IssueNotifyMethodCodes.Sms:
logger.LogInformation("订单 {OrderId} 问题件通知:SMS 通道未接入,已跳过", order.Id);
break;
case IssueNotifyMethodCodes.Feishu:
logger.LogInformation("订单 {OrderId} 问题件通知:飞书通道未接入,已跳过", order.Id);
break;
case IssueNotifyMethodCodes.Wechat:
logger.LogInformation("订单 {OrderId} 问题件通知:微信模板通道未接入,已跳过", order.Id);
break;
default:
logger.LogWarning("订单 {OrderId} 问题件通知:未知方式 {Method},已跳过", order.Id, method);
break;
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "订单 {OrderId} 问题件通知发送失败,方式={Method}", order.Id, method);
}
}
}
private async Task SendInAppAsync(
Order order,
Customer? customer,
string title,
string content,
IEnumerable<int>? extraReceiverUserIds)
{
var receiverIds = new HashSet<int>();
if (customer is { UserId: > 0 })
{
receiverIds.Add(customer.UserId);
}
foreach (var userId in extraReceiverUserIds ?? [])
{
if (userId > 0)
{
receiverIds.Add(userId);
}
}
if (receiverIds.Count == 0)
{
logger.LogInformation("订单 {OrderId} 站内信跳过:无有效接收用户", order.Id);
return;
}
await noticeRepository.InsertAsync(new Notice
{
NoticeType = NoticeTypeMessage,
Title = title,
Content = content,
IsPopup = 0,
ReceiverType = ReceiverTypeUser,
ReceiverIds = OmsDocHelper.SerializeJson(receiverIds.OrderBy(x => x).ToList()) ?? "[]"
});
}
private async Task SendEmailAsync(
Order order,
Customer? customer,
string title,
string content,
int operatorUserId)
{
var email = customer?.Email?.Trim();
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
{
logger.LogInformation("订单 {OrderId} 邮件跳过:客户邮箱无效", order.Id);
return;
}
if (operatorUserId <= 0)
{
logger.LogInformation("订单 {OrderId} 邮件跳过:操作人无效", order.Id);
return;
}
var account = await batchEmailAccountRepository.GetByOwnerAsync(operatorUserId);
if (account == null || string.IsNullOrWhiteSpace(account.SmtpPassword))
{
logger.LogInformation("订单 {OrderId} 邮件跳过:操作人未配置 SMTP", order.Id);
return;
}
await BatchEmailSmtpHelper.SendAsync(
account.SmtpUsername,
account.SmtpPassword,
account.ServerType,
email,
title,
content,
"text",
account.FromName);
}
}

View File

@ -0,0 +1,386 @@
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; }
}
}

View File

@ -10,6 +10,7 @@ using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Shared;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate; using Oms.Domain.OrdersAggregate;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Entities;
@ -125,6 +126,24 @@ public partial class OrderService
CodSum = order.CodSum, CodSum = order.CodSum,
OrderStatus = OmsDocHelper.GetOrderStatusName(order.OrderStatus), OrderStatus = OmsDocHelper.GetOrderStatusName(order.OrderStatus),
Status = order.OrderStatus, Status = order.OrderStatus,
HandleStatus = OrderHandleStatusCodes.IsProcessed(order.HandleStatus)
? OrderHandleStatusCodes.Processed
: OrderHandleStatusCodes.Pending,
HandleStatusName = OrderHandleStatusCodes.GetDisplayName(order.HandleStatus),
IssueType = order.IssueType,
IssueTypeName = IssueTypeCodes.GetDisplayName(order.IssueType),
IssueTitle = order.IssueTitle,
IssueContent = order.IssueContent,
IssueTemplateId = order.IssueTemplateId,
IssueRemark = order.IssueRemark,
IssueTime = order.IssueTime,
ClaimType = order.ClaimType,
ClaimTypeName = ClaimTypeCodes.GetDisplayName(order.ClaimType),
ClaimAmount = order.ClaimAmount,
ClaimCurrency = order.ClaimCurrency,
ClaimReason = order.ClaimReason,
ClaimTime = order.ClaimTime,
PreviousOrderStatus = order.PreviousOrderStatus,
ReceiveTime = order.ReceiveTime, ReceiveTime = order.ReceiveTime,
ShipTime = order.ShipTime, ShipTime = order.ShipTime,
PayTime = order.PayTime, PayTime = order.PayTime,

View File

@ -5,6 +5,7 @@ using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Application.Services.Orders.Issues;
using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.ChannelsAggregate.Repositories;
@ -33,6 +34,9 @@ public partial class OrderService
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher => private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>(); LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
private AutoTriggerRuntimeService AutoTriggerRuntimeService =>
LazyServiceProvider.GetRequiredService<AutoTriggerRuntimeService>();
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input) private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input)
{ {
if (!string.IsNullOrWhiteSpace(input.RequestId)) if (!string.IsNullOrWhiteSpace(input.RequestId))
@ -135,6 +139,15 @@ public partial class OrderService
await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver); await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver);
try
{
await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, CurrentUser.GetId<int>());
}
catch
{
// 自动触发失败不影响预报主流程
}
return await BuildParcelCreateOutputAsync(order); return await BuildParcelCreateOutputAsync(order);
} }
catch (BusinessException ex) catch (BusinessException ex)

View File

@ -70,10 +70,20 @@ public partial class OrderService : ApplicationService
x => x.Status, x => x.Status,
x => x.Count, x => x.Count,
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
var handleRows = await OrderRepository.GetHandleStatusCountsByFilterAsync(filter);
var handleCounts = handleRows.ToDictionary(
x => x.Key,
x => new OrderHandleStatusCountsOutput
{
Pending = x.Value.Pending,
Processed = x.Value.Processed
},
StringComparer.OrdinalIgnoreCase);
return new OrderStatusCountsOutput return new OrderStatusCountsOutput
{ {
Total = rows.Sum(x => x.Count), Total = rows.Sum(x => x.Count),
Counts = counts Counts = counts,
HandleCounts = handleCounts
}; };
} }
@ -387,6 +397,7 @@ public partial class OrderService : ApplicationService
new() new()
{ {
Status = input.Status, Status = input.Status,
HandleStatus = input.HandleStatus,
Keyword = input.Keyword, Keyword = input.Keyword,
CreateTimeStart = input.CreateTimeStart, CreateTimeStart = input.CreateTimeStart,
CreateTimeEnd = input.CreateTimeEnd, CreateTimeEnd = input.CreateTimeEnd,

View File

@ -168,7 +168,7 @@ public static class OmsDocHelper
OrderStatusCodes.DeliveryFailed => "派送失败", OrderStatusCodes.DeliveryFailed => "派送失败",
OrderStatusCodes.Problematic => "问题件", OrderStatusCodes.Problematic => "问题件",
OrderStatusCodes.Deleted => "已删除", OrderStatusCodes.Deleted => "已删除",
OrderStatusCodes.Claimed => "已认领", OrderStatusCodes.Claimed => "理赔件",
_ => statusCode _ => statusCode
}; };
} }

View File

@ -0,0 +1,32 @@
namespace Oms.Domain.OrdersAggregate.Consts;
public static class ClaimTypeCodes
{
public const string Lost = "lost";
public const string Damaged = "damaged";
public const string Delay = "delay";
public const string Customs = "customs";
public const string Fee = "fee";
public const string Other = "other";
public static readonly HashSet<string> All =
[
Lost,
Damaged,
Delay,
Customs,
Fee,
Other
];
public static string GetDisplayName(string? code) => code?.Trim().ToLowerInvariant() switch
{
Lost => "丢件",
Damaged => "破损",
Delay => "延误",
Customs => "清关理赔",
Fee => "费用争议",
Other => "其他",
_ => code ?? string.Empty
};
}

View File

@ -0,0 +1,8 @@
namespace Oms.Domain.OrdersAggregate.Consts;
public static class OrderExceptionEventTypeCodes
{
public const string MarkProblematic = "mark_problematic";
public const string MarkClaimed = "mark_claimed";
public const string HandleProcessed = "handle_processed";
}

View File

@ -0,0 +1,25 @@
namespace Oms.Domain.OrdersAggregate.Consts;
public static class OrderExceptionStatusRules
{
public static readonly HashSet<string> ForbiddenSourceStatuses = new(StringComparer.OrdinalIgnoreCase)
{
OrderStatusCodes.Draft,
OrderStatusCodes.Deleted,
OrderStatusCodes.Problematic,
OrderStatusCodes.Claimed
};
public static bool CanMarkException(string? orderStatus) =>
!string.IsNullOrWhiteSpace(orderStatus)
&& !ForbiddenSourceStatuses.Contains(orderStatus.Trim());
public static string GetForbiddenReason(string? orderStatus) => orderStatus?.Trim().ToLowerInvariant() switch
{
OrderStatusCodes.Draft => "草稿订单不可标记",
OrderStatusCodes.Deleted => "已删除订单不可标记",
OrderStatusCodes.Problematic => "已是问题件",
OrderStatusCodes.Claimed => "已是理赔件",
_ => "订单状态不允许标记"
};
}

View File

@ -0,0 +1,27 @@
namespace Oms.Domain.OrdersAggregate.Consts;
public static class OrderHandleStatusCodes
{
public const string Pending = "pending";
public const string Processed = "processed";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{
Pending,
Processed
};
public static string GetDisplayName(string? code) => code?.Trim().ToLowerInvariant() switch
{
Pending => "待处理",
Processed => "已处理",
_ => "待处理"
};
public static bool IsPending(string? code) =>
string.IsNullOrWhiteSpace(code)
|| string.Equals(code.Trim(), Pending, StringComparison.OrdinalIgnoreCase);
public static bool IsProcessed(string? code) =>
string.Equals(code?.Trim(), Processed, StringComparison.OrdinalIgnoreCase);
}

View File

@ -54,6 +54,35 @@ public class Order : OmsAuditedAggregateRoot
public string OrderStatus { get; set; } = string.Empty; public string OrderStatus { get; set; } = string.Empty;
/// <summary>
/// 问题件/已理赔子状态:pending-待处理,processed-已处理。
/// </summary>
public string? HandleStatus { get; set; }
public string? IssueType { get; set; }
public string? IssueTitle { get; set; }
public string? IssueContent { get; set; }
public int? IssueTemplateId { get; set; }
public string? IssueRemark { get; set; }
public DateTime? IssueTime { get; set; }
public string? ClaimType { get; set; }
public decimal? ClaimAmount { get; set; }
public string? ClaimCurrency { get; set; }
public string? ClaimReason { get; set; }
public DateTime? ClaimTime { get; set; }
public string? PreviousOrderStatus { get; set; }
public int? WarehouseId { get; set; } public int? WarehouseId { get; set; }
public int? CustomerId { get; set; } public int? CustomerId { get; set; }

View File

@ -0,0 +1,40 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.OrdersAggregate.Entities;
[SugarTable("t_order_exception_event")]
public class OrderExceptionEvent : OmsAuditedAggregateRoot
{
public int OrderId { get; set; }
public string EventType { get; set; } = string.Empty;
public string? FromStatus { get; set; }
public string? ToStatus { get; set; }
public string? FromHandleStatus { get; set; }
public string? ToHandleStatus { get; set; }
public string? IssueType { get; set; }
public string? Title { get; set; }
public string? Content { get; set; }
public int? IssueTemplateId { get; set; }
public string? NotifyMethod { get; set; }
public string? ClaimType { get; set; }
public decimal? ClaimAmount { get; set; }
public string? ClaimCurrency { get; set; }
public string? ClaimReason { get; set; }
public string? Remark { get; set; }
}

View File

@ -7,6 +7,8 @@ public class OrderListFilter
{ {
public string? Status { get; set; } public string? Status { get; set; }
public string? HandleStatus { get; set; }
public string? Keyword { get; set; } public string? Keyword { get; set; }
public DateTime? CreateTimeStart { get; set; } public DateTime? CreateTimeStart { get; set; }

View File

@ -0,0 +1,8 @@
using Fake.Domain.Repositories;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Domain.OrdersAggregate.Repositories;
public interface IOrderExceptionEventRepository : IRepository<OrderExceptionEvent>
{
}

View File

@ -75,6 +75,10 @@ public interface IOrderRepository : IRepository<Order>
OrderListFilter filter, OrderListFilter filter,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<Dictionary<string, (int Pending, int Processed)>> GetHandleStatusCountsByFilterAsync(
OrderListFilter filter,
CancellationToken cancellationToken = default);
Task<int> CountByFilterAndCreateTimeRangeAsync( Task<int> CountByFilterAndCreateTimeRangeAsync(
OrderListFilter filter, OrderListFilter filter,
DateTime startInclusive, DateTime startInclusive,

View File

@ -0,0 +1,13 @@
using Fake.DependencyInjection;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
namespace Oms.SqlSugarCore.Orders;
public class OrderExceptionEventRepository
: OmsSqlSugarRepository<OrderExceptionEvent>,
IOrderExceptionEventRepository,
IScopedDependency
{
}

View File

@ -73,6 +73,23 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
query = query.Where(x => x.CreateTime <= end); query = query.Where(x => x.CreateTime <= end);
} }
var handleStatus = filter.HandleStatus?.Trim();
if (!string.IsNullOrWhiteSpace(handleStatus)
&& !string.Equals(handleStatus, "all", StringComparison.OrdinalIgnoreCase))
{
if (OrderHandleStatusCodes.IsProcessed(handleStatus))
{
query = query.Where(x => x.HandleStatus == OrderHandleStatusCodes.Processed);
}
else
{
query = query.Where(x =>
x.HandleStatus == null
|| x.HandleStatus == string.Empty
|| x.HandleStatus == OrderHandleStatusCodes.Pending);
}
}
query = OrderListScopeFilterApplier.Apply(query, filter); query = OrderListScopeFilterApplier.Apply(query, filter);
return query; return query;
@ -425,6 +442,49 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task<Dictionary<string, (int Pending, int Processed)>> GetHandleStatusCountsByFilterAsync(
OrderListFilter filter,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var baseFilter = new OrderListFilter
{
Keyword = filter.Keyword,
CreateTimeStart = filter.CreateTimeStart,
CreateTimeEnd = filter.CreateTimeEnd,
CustomerId = filter.CustomerId,
ScopeOrganizationIds = filter.ScopeOrganizationIds,
ScopeSecretLevels = filter.ScopeSecretLevels,
ScopeDataScope = filter.ScopeDataScope,
ScopePersonalOwnerMapId = filter.ScopePersonalOwnerMapId,
ScopePersonalOwnerAccount = filter.ScopePersonalOwnerAccount,
ScopeOwnerMapIds = filter.ScopeOwnerMapIds,
ScopeBizFilters = filter.ScopeBizFilters,
ScopeCustomFilters = filter.ScopeCustomFilters
};
var result = new Dictionary<string, (int Pending, int Processed)>(StringComparer.OrdinalIgnoreCase)
{
[OrderStatusCodes.Problematic] = (0, 0),
[OrderStatusCodes.Claimed] = (0, 0)
};
foreach (var status in new[] { OrderStatusCodes.Problematic, OrderStatusCodes.Claimed })
{
baseFilter.Status = status;
var query = ApplyListFilter(db.Queryable<Order>(), baseFilter);
var rows = await query
.Select(x => x.HandleStatus)
.ToListAsync(cancellationToken);
var pending = rows.Count(OrderHandleStatusCodes.IsPending);
var processed = rows.Count(OrderHandleStatusCodes.IsProcessed);
result[status] = (pending, processed);
}
return result;
}
public async Task<int> CountByFilterAndCreateTimeRangeAsync( public async Task<int> CountByFilterAndCreateTimeRangeAsync(
OrderListFilter filter, OrderListFilter filter,
DateTime startInclusive, DateTime startInclusive,