diff --git a/db.sql b/db.sql index 402481d..ded322e 100644 --- a/db.sql +++ b/db.sql @@ -75,6 +75,19 @@ CREATE TABLE `t_order` ( `SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本', `SalesProductPriceVersion` VARCHAR(64) 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', `CustomerId` 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`) ) 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` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `ProductCode` VARCHAR(64) NOT NULL COMMENT '产品编码', diff --git a/sql/migrate_t_order_exception_event.sql b/sql/migrate_t_order_exception_event.sql new file mode 100644 index 0000000..408605e --- /dev/null +++ b/sql/migrate_t_order_exception_event.sql @@ -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='订单异常事件表'; diff --git a/sql/migrate_t_order_exception_fields.sql b/sql/migrate_t_order_exception_fields.sql new file mode 100644 index 0000000..1db1f43 --- /dev/null +++ b/sql/migrate_t_order_exception_fields.sql @@ -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`; diff --git a/sql/migrate_t_order_handle_status.sql b/sql/migrate_t_order_handle_status.sql new file mode 100644 index 0000000..8c77435 --- /dev/null +++ b/sql/migrate_t_order_handle_status.sql @@ -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'); diff --git a/src/Oms.Application/Dtos/Orders/OrderExceptionDtos.cs b/src/Oms.Application/Dtos/Orders/OrderExceptionDtos.cs new file mode 100644 index 0000000..2f442a5 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderExceptionDtos.cs @@ -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 Failures { get; set; } = []; +} + +public class OrderMarkProblematicInput +{ + public List 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 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 OrderIds { get; set; } = []; + + public string? HandleStatus { get; set; } + + public string? Remark { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderHandleStatusCountsOutput.cs b/src/Oms.Application/Dtos/Orders/OrderHandleStatusCountsOutput.cs new file mode 100644 index 0000000..533700b --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderHandleStatusCountsOutput.cs @@ -0,0 +1,13 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 问题件/已理赔子状态数量。 +/// +public class OrderHandleStatusCountsOutput +{ + public int Pending { get; set; } + + public int Processed { get; set; } + + public int Total => Pending + Processed; +} diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs index 26c2c28..883d9b6 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs @@ -145,6 +145,44 @@ public class OrderOutput /// public string Status { get; set; } = string.Empty; + /// + /// 子状态 code:pending / processed。 + /// + public string? HandleStatus { get; set; } + + /// + /// 子状态名称。 + /// + 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; } + /// /// 收货时间。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderPaginatedInput.cs b/src/Oms.Application/Dtos/Orders/OrderPaginatedInput.cs index 159b148..a2075d6 100644 --- a/src/Oms.Application/Dtos/Orders/OrderPaginatedInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderPaginatedInput.cs @@ -24,6 +24,13 @@ public class OrderPaginatedInput [global::System.Text.Json.Serialization.JsonPropertyName("status")] public string? Status { get; set; } + /// + /// 子状态:all / pending / processed(仅问题件、已理赔 Tab 使用)。 + /// + [Newtonsoft.Json.JsonProperty("handleStatus")] + [global::System.Text.Json.Serialization.JsonPropertyName("handleStatus")] + public string? HandleStatus { get; set; } + /// /// 关键词。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderStatusCountsOutput.cs b/src/Oms.Application/Dtos/Orders/OrderStatusCountsOutput.cs index 42991af..9568dd2 100644 --- a/src/Oms.Application/Dtos/Orders/OrderStatusCountsOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderStatusCountsOutput.cs @@ -14,4 +14,10 @@ public class OrderStatusCountsOutput /// 按 OrderStatus 分组的数量,key 为状态码。 /// public Dictionary Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 问题件/已理赔子状态数量,key 为 OrderStatus(problematic/claimed)。 + /// + public Dictionary HandleCounts { get; set; } = + new(StringComparer.OrdinalIgnoreCase); } diff --git a/src/Oms.Application/Services/Operations/InboundRecordService.cs b/src/Oms.Application/Services/Operations/InboundRecordService.cs index e5b1d50..22c5033 100644 --- a/src/Oms.Application/Services/Operations/InboundRecordService.cs +++ b/src/Oms.Application/Services/Operations/InboundRecordService.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Operations; using Oms.Application.Dtos.Shared; using Oms.Application.Services.Orders; +using Oms.Application.Services.Orders.Issues; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.OperationsAggregate.Entities; @@ -24,6 +25,8 @@ public class InboundRecordService : ApplicationService private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService(); private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); private OrderService OrderService => LazyServiceProvider.GetRequiredService(); + private AutoTriggerRuntimeService AutoTriggerRuntimeService => + LazyServiceProvider.GetRequiredService(); /// /// 按单号查询订单(入库扫描展示销售产品、目的国,只读)。 @@ -130,6 +133,20 @@ public class InboundRecordService : ApplicationService input.Weight); 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(savedEntity); } diff --git a/src/Oms.Application/Services/Operations/OutboundRecordService.cs b/src/Oms.Application/Services/Operations/OutboundRecordService.cs index 0ebd563..30f84ff 100644 --- a/src/Oms.Application/Services/Operations/OutboundRecordService.cs +++ b/src/Oms.Application/Services/Operations/OutboundRecordService.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Operations; using Oms.Application.Dtos.Shared; using Oms.Application.Services.Orders; +using Oms.Application.Services.Orders.Issues; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.OperationsAggregate.Entities; @@ -28,6 +29,8 @@ public class OutboundRecordService : ApplicationService private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService(); private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); private OrderService OrderService => LazyServiceProvider.GetRequiredService(); + private AutoTriggerRuntimeService AutoTriggerRuntimeService => + LazyServiceProvider.GetRequiredService(); /// /// 按单号查询订单(出库扫描展示销售产品、目的国、服务渠道,只读)。 @@ -157,6 +160,20 @@ public class OutboundRecordService : ApplicationService input.Weight); 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(savedEntity); } diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionMatcher.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionMatcher.cs new file mode 100644 index 0000000..8467375 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionMatcher.cs @@ -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 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); +} diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs new file mode 100644 index 0000000..c0f88f4 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs @@ -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 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); + } + } +} diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerTimingCodes.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerTimingCodes.cs new file mode 100644 index 0000000..36bc71c --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerTimingCodes.cs @@ -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; + } +} diff --git a/src/Oms.Application/Services/Orders/Issues/IssuePlaceholderRenderer.cs b/src/Oms.Application/Services/Orders/Issues/IssuePlaceholderRenderer.cs new file mode 100644 index 0000000..1022434 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/IssuePlaceholderRenderer.cs @@ -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 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; + } +} diff --git a/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs b/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs new file mode 100644 index 0000000..d8b41ac --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs @@ -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? ExtraReceiverUserIds { get; set; } +} + +public class OrderExceptionWorkflowService( + IOrderRepository orderRepository, + IOrderExceptionEventRepository orderExceptionEventRepository, + IIssueTemplateRepository issueTemplateRepository, + OrderIssueNotificationService issueNotificationService) : IScopedDependency +{ + public async Task 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 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; + } +} diff --git a/src/Oms.Application/Services/Orders/Issues/OrderIssueNotificationService.cs b/src/Oms.Application/Services/Orders/Issues/OrderIssueNotificationService.cs new file mode 100644 index 0000000..e7730d0 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/OrderIssueNotificationService.cs @@ -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 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? 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? extraReceiverUserIds) + { + var receiverIds = new HashSet(); + 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); + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.Exception.cs b/src/Oms.Application/Services/Orders/OrderService.Exception.cs new file mode 100644 index 0000000..502ea6f --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.Exception.cs @@ -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(); + + private IOrderExceptionEventRepository OrderExceptionEventRepository => + LazyServiceProvider.GetRequiredService(); + + private OrderExceptionWorkflowService ExceptionWorkflowService => + LazyServiceProvider.GetRequiredService(); + + /// + /// 批量创建问题件。 + /// + [HttpPost("/order/mark-problematic")] + public async Task 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); + } + + /// + /// 批量创建理赔件。 + /// + [HttpPost("/order/mark-claimed")] + public async Task 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); + } + + /// + /// 批量更新处理子状态。 + /// + [HttpPost("/order/handle-status/update")] + public async Task 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 ExecuteProblematicBatchAsync( + IReadOnlyCollection orderIds, + ProblematicMarkRequest request) + { + var ids = NormalizeOrderIds(orderIds); + var result = new OrderBatchActionResultOutput(); + var operatorUserId = CurrentUser.GetId(); + 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 ExecuteExceptionBatchAsync( + IReadOnlyCollection orderIds, + Func tryApply, + Func 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 NormalizeOrderIds(IReadOnlyCollection? 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 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; } + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 17e380c..686ee49 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -10,6 +10,7 @@ using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; using Oms.Domain.OrdersAggregate; +using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.SystemAggregate.Entities; @@ -125,6 +126,24 @@ public partial class OrderService CodSum = order.CodSum, OrderStatus = OmsDocHelper.GetOrderStatusName(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, ShipTime = order.ShipTime, PayTime = order.PayTime, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 6226fb8..db4a712 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -5,6 +5,7 @@ using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Shared; +using Oms.Application.Services.Orders.Issues; using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; @@ -33,6 +34,9 @@ public partial class OrderService private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher => LazyServiceProvider.GetRequiredService(); + private AutoTriggerRuntimeService AutoTriggerRuntimeService => + LazyServiceProvider.GetRequiredService(); + private async Task CreateParcelCoreAsync(OrderParcelCreateInput input) { if (!string.IsNullOrWhiteSpace(input.RequestId)) @@ -135,6 +139,15 @@ public partial class OrderService await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver); + try + { + await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, CurrentUser.GetId()); + } + catch + { + // 自动触发失败不影响预报主流程 + } + return await BuildParcelCreateOutputAsync(order); } catch (BusinessException ex) diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 2774c0c..a0a6191 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -70,10 +70,20 @@ public partial class OrderService : ApplicationService x => x.Status, x => x.Count, 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 { Total = rows.Sum(x => x.Count), - Counts = counts + Counts = counts, + HandleCounts = handleCounts }; } @@ -387,6 +397,7 @@ public partial class OrderService : ApplicationService new() { Status = input.Status, + HandleStatus = input.HandleStatus, Keyword = input.Keyword, CreateTimeStart = input.CreateTimeStart, CreateTimeEnd = input.CreateTimeEnd, diff --git a/src/Oms.Application/Shared/OmsDocHelper.cs b/src/Oms.Application/Shared/OmsDocHelper.cs index 102b40d..83441cd 100644 --- a/src/Oms.Application/Shared/OmsDocHelper.cs +++ b/src/Oms.Application/Shared/OmsDocHelper.cs @@ -168,7 +168,7 @@ public static class OmsDocHelper OrderStatusCodes.DeliveryFailed => "派送失败", OrderStatusCodes.Problematic => "问题件", OrderStatusCodes.Deleted => "已删除", - OrderStatusCodes.Claimed => "已认领", + OrderStatusCodes.Claimed => "理赔件", _ => statusCode }; } diff --git a/src/Oms.Domain/OrdersAggregate/Consts/ClaimTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/ClaimTypeCodes.cs new file mode 100644 index 0000000..6946fe3 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/ClaimTypeCodes.cs @@ -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 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 + }; +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderExceptionEventTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderExceptionEventTypeCodes.cs new file mode 100644 index 0000000..ba1132a --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderExceptionEventTypeCodes.cs @@ -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"; +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderExceptionStatusRules.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderExceptionStatusRules.cs new file mode 100644 index 0000000..b188712 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderExceptionStatusRules.cs @@ -0,0 +1,25 @@ +namespace Oms.Domain.OrdersAggregate.Consts; + +public static class OrderExceptionStatusRules +{ + public static readonly HashSet 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 => "已是理赔件", + _ => "订单状态不允许标记" + }; +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderHandleStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderHandleStatusCodes.cs new file mode 100644 index 0000000..d3e3eac --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderHandleStatusCodes.cs @@ -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 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); +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index c7239c7..2933a77 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -54,6 +54,35 @@ public class Order : OmsAuditedAggregateRoot public string OrderStatus { get; set; } = string.Empty; + /// + /// 问题件/已理赔子状态:pending-待处理,processed-已处理。 + /// + 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? CustomerId { get; set; } diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderExceptionEvent.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderExceptionEvent.cs new file mode 100644 index 0000000..8f368ba --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderExceptionEvent.cs @@ -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; } +} diff --git a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs index 2890b24..b3bf3af 100644 --- a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs +++ b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs @@ -7,6 +7,8 @@ public class OrderListFilter { public string? Status { get; set; } + public string? HandleStatus { get; set; } + public string? Keyword { get; set; } public DateTime? CreateTimeStart { get; set; } diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderExceptionEventRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderExceptionEventRepository.cs new file mode 100644 index 0000000..7e32dd2 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderExceptionEventRepository.cs @@ -0,0 +1,8 @@ +using Fake.Domain.Repositories; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Domain.OrdersAggregate.Repositories; + +public interface IOrderExceptionEventRepository : IRepository +{ +} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index 5821584..df46cf2 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -75,6 +75,10 @@ public interface IOrderRepository : IRepository OrderListFilter filter, CancellationToken cancellationToken = default); + Task> GetHandleStatusCountsByFilterAsync( + OrderListFilter filter, + CancellationToken cancellationToken = default); + Task CountByFilterAndCreateTimeRangeAsync( OrderListFilter filter, DateTime startInclusive, diff --git a/src/Oms.SqlSugarCore/Orders/OrderExceptionEventRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderExceptionEventRepository.cs new file mode 100644 index 0000000..d883d8b --- /dev/null +++ b/src/Oms.SqlSugarCore/Orders/OrderExceptionEventRepository.cs @@ -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, + IOrderExceptionEventRepository, + IScopedDependency +{ +} diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index b87c18e..47755f6 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -73,6 +73,23 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I 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); return query; @@ -425,6 +442,49 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I .ToListAsync(cancellationToken); } + public async Task> 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(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(), 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 CountByFilterAndCreateTimeRangeAsync( OrderListFilter filter, DateTime startInclusive,