feat: 完善揽收单与问题件处理能力

补齐揽收单字段/并发戳与问题件处理人配置,扩展异常工作流与下单同步揽收出参;移除已收货状态并补充相关 SQL 脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-08-04 11:49:23 +08:00
parent c02cb21049
commit b2355104cf
34 changed files with 1201 additions and 47 deletions

View File

@ -1,21 +1,31 @@
-- 揽收单(独立履约/结算)+ 与物流单 1:N 关联;物流单冗余到仓方式与揽收 FK。
-- 对齐现表公共字段:ConcurrencyStamp / OrganizationId / SecretLevel;发件快照对齐 t_order_sender。
-- 文档:前端 docs/揽收单与物流单串联方案.md
-- 执行前请备份。
-- 若表已按旧版脚本建好,请改跑 sql/20260803_pickup_order_align_fields.sql
CREATE TABLE IF NOT EXISTS `t_pickup_order` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
`PickupNo` VARCHAR(32) NOT NULL COMMENT '揽收单号',
`RequestId` VARCHAR(64) NULL COMMENT '请求幂等ID',
`CustomerId` INT NOT NULL COMMENT '客户Id',
`OrganizationId` CHAR(36) NULL COMMENT '数据归属组织',
`SecretLevel` TINYINT NOT NULL DEFAULT 2 COMMENT '数据密级 1-4',
`SenderAddressId` INT NOT NULL COMMENT '发件地址簿Id',
`SenderName` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '取件联系人快照',
`SenderPhone` VARCHAR(32) NULL COMMENT '取件电话快照',
`SenderCompany` VARCHAR(255) NULL COMMENT '公司快照',
`SenderCountry` INT NULL COMMENT '国家 t_country.Id 快照',
`SenderCountryCode` VARCHAR(8) NULL COMMENT '国家码快照',
`SenderProvince` VARCHAR(50) NULL COMMENT '省快照',
`SenderCity` VARCHAR(50) NULL COMMENT '市快照',
`SenderZipCode` VARCHAR(50) NULL COMMENT '邮编快照',
`SenderStreetAddress1` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '地址1快照',
`SenderStreetAddress2` VARCHAR(500) NULL COMMENT '地址2快照',
`SenderStreetAddress3` VARCHAR(500) NULL COMMENT '地址3快照',
`SenderHouseNumber` VARCHAR(50) NULL COMMENT '门牌号快照',
`SenderPhone` VARCHAR(50) NULL COMMENT '手机快照',
`SenderTelePhone` VARCHAR(50) NULL COMMENT '电话快照',
`SenderEmail` VARCHAR(60) NULL COMMENT '邮箱快照',
`WarehouseId` INT NOT NULL COMMENT '送到仓库Id',
`CarrierCode` VARCHAR(64) NULL COMMENT '承运商编码(draft可空)',
`CarrierService` VARCHAR(64) NULL COMMENT '承运服务(draft可空)',
@ -40,25 +50,32 @@ CREATE TABLE IF NOT EXISTS `t_pickup_order` (
`SettlementBatchNo` VARCHAR(64) NULL COMMENT '结算批次号',
`SettledAt` DATETIME NULL COMMENT '结算时间',
`Remark` VARCHAR(512) NULL COMMENT '备注',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
-- CREATE 可用 DEFAULT (UUID());若环境同样禁非确定默认值,可去掉 DEFAULT,由应用写入
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`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`),
UNIQUE KEY `uk_pickup_no` (`PickupNo`),
UNIQUE KEY `uk_pickup_request_id` (`RequestId`),
KEY `idx_pickup_customer_status` (`CustomerId`, `Status`, `CreateTime`),
KEY `idx_pickup_warehouse_time` (`WarehouseId`, `ExpectPickupTime`),
KEY `idx_pickup_pay` (`PayStatus`),
KEY `idx_pickup_settle` (`SettlementStatus`)
KEY `idx_pickup_settle` (`SettlementStatus`),
KEY `idx_pickup_org_secret_ctime` (`OrganizationId`, `SecretLevel`, `CreateTime`)
) COMMENT='揽收单(上门取件履约+对客收款+承运结算)';
CREATE TABLE IF NOT EXISTS `t_pickup_order_line` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
`PickupOrderId` INT NOT NULL COMMENT '揽收单Id',
`OrderId` INT NOT NULL COMMENT '物流订单Id',
`ServiceNo` VARCHAR(64) NULL COMMENT '运单号快照(便于列表)',
`ClientNo` VARCHAR(64) NULL COMMENT '客户单号快照',
`SortNo` INT NOT NULL DEFAULT 0 COMMENT '展示序',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',

View File

@ -0,0 +1,53 @@
-- 对齐已创建的 t_pickup_order / t_pickup_order_line 与现表公共字段。
-- 适用于已执行旧版 20260803_pickup_order.sql 的环境;列已存在时对应语句会报错,可跳过。
-- 执行前请备份。
--
-- 注意:ConcurrencyStamp 勿写 DEFAULT (UUID()),在开启复制/GTID 时 ALTER 会报 1674。
-- 正确做法:先可空列 → UPDATE 回填 → 再改为 NOT NULL。
-- ===== t_pickup_order =====
ALTER TABLE `t_pickup_order`
ADD COLUMN `RequestId` VARCHAR(64) NULL COMMENT '请求幂等ID' AFTER `PickupNo`,
ADD COLUMN `OrganizationId` CHAR(36) NULL COMMENT '数据归属组织' AFTER `CustomerId`,
ADD COLUMN `SecretLevel` TINYINT NOT NULL DEFAULT 2 COMMENT '数据密级 1-4' AFTER `OrganizationId`;
ALTER TABLE `t_pickup_order`
ADD COLUMN `SenderCountry` INT NULL COMMENT '国家 t_country.Id 快照' AFTER `SenderCompany`,
ADD COLUMN `SenderStreetAddress3` VARCHAR(500) NULL COMMENT '地址3快照' AFTER `SenderStreetAddress2`,
ADD COLUMN `SenderHouseNumber` VARCHAR(50) NULL COMMENT '门牌号快照' AFTER `SenderStreetAddress3`,
ADD COLUMN `SenderTelePhone` VARCHAR(50) NULL COMMENT '电话快照' AFTER `SenderPhone`,
ADD COLUMN `SenderEmail` VARCHAR(60) NULL COMMENT '邮箱快照' AFTER `SenderTelePhone`;
-- 手机字段长度与现表对齐(若已是 50 可忽略报错)
ALTER TABLE `t_pickup_order`
MODIFY COLUMN `SenderPhone` VARCHAR(50) NULL COMMENT '手机快照';
-- ConcurrencyStamp(复制安全写法)
ALTER TABLE `t_pickup_order`
ADD COLUMN `ConcurrencyStamp` CHAR(36) NULL COMMENT '并发戳' AFTER `IsDeleted`;
UPDATE `t_pickup_order`
SET `ConcurrencyStamp` = UUID()
WHERE `ConcurrencyStamp` IS NULL OR `ConcurrencyStamp` = '';
ALTER TABLE `t_pickup_order`
MODIFY COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL COMMENT '并发戳';
ALTER TABLE `t_pickup_order`
ADD UNIQUE KEY `uk_pickup_request_id` (`RequestId`),
ADD KEY `idx_pickup_org_secret_ctime` (`OrganizationId`, `SecretLevel`, `CreateTime`);
-- ===== t_pickup_order_line =====
ALTER TABLE `t_pickup_order_line`
ADD COLUMN `ServiceNo` VARCHAR(64) NULL COMMENT '运单号快照(便于列表)' AFTER `OrderId`,
ADD COLUMN `ClientNo` VARCHAR(64) NULL COMMENT '客户单号快照' AFTER `ServiceNo`;
ALTER TABLE `t_pickup_order_line`
ADD COLUMN `ConcurrencyStamp` CHAR(36) NULL COMMENT '并发戳' AFTER `IsDeleted`;
UPDATE `t_pickup_order_line`
SET `ConcurrencyStamp` = UUID()
WHERE `ConcurrencyStamp` IS NULL OR `ConcurrencyStamp` = '';
ALTER TABLE `t_pickup_order_line`
MODIFY COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL COMMENT '并发戳';

View File

@ -0,0 +1,53 @@
-- 幂等补 ConcurrencyStamp:已存在则跳过 ADD,只做回填/NOT NULL。
-- 执行前请备份。
-- 先看现状(可选)
SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE, COLUMN_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('t_pickup_order', 't_pickup_order_line')
AND COLUMN_NAME = 'ConcurrencyStamp';
-- ===== t_pickup_order =====
SET @sql := (
SELECT IF(
EXISTS(
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 't_pickup_order'
AND COLUMN_NAME = 'ConcurrencyStamp'
),
'SELECT ''t_pickup_order.ConcurrencyStamp already exists'' AS info',
'ALTER TABLE `t_pickup_order` ADD COLUMN `ConcurrencyStamp` CHAR(36) NULL COMMENT ''并发戳'' AFTER `IsDeleted`'
)
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE `t_pickup_order`
SET `ConcurrencyStamp` = UUID()
WHERE `ConcurrencyStamp` IS NULL OR `ConcurrencyStamp` = '';
ALTER TABLE `t_pickup_order`
MODIFY COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL COMMENT '并发戳';
-- ===== t_pickup_order_line =====
SET @sql := (
SELECT IF(
EXISTS(
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 't_pickup_order_line'
AND COLUMN_NAME = 'ConcurrencyStamp'
),
'SELECT ''t_pickup_order_line.ConcurrencyStamp already exists'' AS info',
'ALTER TABLE `t_pickup_order_line` ADD COLUMN `ConcurrencyStamp` CHAR(36) NULL COMMENT ''并发戳'' AFTER `IsDeleted`'
)
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE `t_pickup_order_line`
SET `ConcurrencyStamp` = UUID()
WHERE `ConcurrencyStamp` IS NULL OR `ConcurrencyStamp` = '';
ALTER TABLE `t_pickup_order_line`
MODIFY COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL COMMENT '并发戳';

View File

@ -0,0 +1,9 @@
-- 问题模板:处理人类型 + 指定用户
ALTER TABLE `t_issue_template`
ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT '处理人类型:customer=订单所属客户 user=指定用户' AFTER `NotifyMethod`,
ADD COLUMN `HandlerUserId` INT NULL COMMENT '指定处理人用户Id(HandlerType=user 时必填)' AFTER `HandlerType`;
-- 问题事件:处理人快照(运行时解析)
ALTER TABLE `t_order_exception_event`
ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT '处理人类型' AFTER `HandlerNames`,
ADD COLUMN `HandlerUserId` INT NULL COMMENT '处理人用户Id(customer 时为订单客户账号)' AFTER `HandlerType`;

View File

@ -0,0 +1,3 @@
-- 问题处理:事件级处理人快照(订单可有多条问题记录)
ALTER TABLE `t_order_exception_event`
ADD COLUMN `HandlerNames` VARCHAR(256) NULL COMMENT '处理人快照' AFTER `NotifyMethod`;

View File

@ -0,0 +1,14 @@
-- 废弃订单状态 received(已收货):入库后直接进 pendingAllocation / pendingPayment,不再停留 received
-- 历史数据:已入库的归待排货;若已有应付且未支付则归待支付
UPDATE `t_order`
SET
`OrderStatus` = CASE
WHEN IFNULL(`IsPaid`, 0) > 0 THEN 'pendingShip'
WHEN IFNULL(`PayableAmount`, 0) > 0
AND (`AllocationPendingReason` IS NULL OR TRIM(`AllocationPendingReason`) = '')
THEN 'pendingPayment'
ELSE 'pendingAllocation'
END,
`UpdateTime` = NOW()
WHERE `OrderStatus` = 'received';

View File

@ -23,6 +23,14 @@ public class IssueTemplateOutput
public List<string> NotifyMethods { get; set; } = [];
public string? HandlerType { get; set; }
public string? HandlerTypeName { get; set; }
public int? HandlerUserId { get; set; }
public string? HandlerUserName { get; set; }
public byte Status { get; set; }
public DateTime CreateTime { get; set; }

View File

@ -19,5 +19,9 @@ public class IssueTemplateSaveInput
public string? NotifyMethod { get; set; }
public string? HandlerType { get; set; }
public int? HandlerUserId { get; set; }
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
}

View File

@ -21,6 +21,9 @@ public class OrderAllocationTraceOutput
public decimal? ChargeableWeight { get; set; }
/// <summary>订单解析出的价格分区键(用于对照买价明细分区)。</summary>
public List<string> ZoneKeys { get; set; } = [];
public string? ChargingStage { get; set; }
public string? ChargingStageName { get; set; }

View File

@ -29,6 +29,67 @@ public class OrderMarkProblematicInput
public string? Content { get; set; }
public string? Remark { get; set; }
public string? HandlerNames { get; set; }
}
public class OrderExceptionEventProcessInput
{
public int Id { get; set; }
public string? Remark { get; set; }
}
public class OrderExceptionEventOutput
{
public int Id { get; set; }
public int OrderId { get; set; }
public string EventType { get; set; } = string.Empty;
public string? IssueType { get; set; }
public string? IssueTypeName { get; set; }
public string? Title { get; set; }
public string? Content { get; set; }
public string? NotifyMethod { get; set; }
public string? NotifyMethodNames { get; set; }
public string? HandlerNames { get; set; }
public string? HandlerType { get; set; }
public string? HandlerTypeName { get; set; }
public int? HandlerUserId { get; set; }
public string? HandleStatus { get; set; }
public string? HandleStatusName { get; set; }
public string? CreateBy { get; set; }
public DateTime CreateTime { get; set; }
public string? ProcessedBy { get; set; }
public DateTime? ProcessedTime { get; set; }
public string? Remark { get; set; }
}
public class OrderExceptionEventListOutput
{
public List<OrderExceptionEventOutput> Items { get; set; } = [];
public int PendingCount { get; set; }
public int ProcessedCount { get; set; }
}
public class OrderMarkClaimedInput

View File

@ -136,10 +136,15 @@ public class OrderOutput
public int? PickupOrderId { get; set; }
/// <summary>
/// 关联揽收单号。
/// 关联揽收系统单号(OMS PickupNo)。
/// </summary>
public string? PickupNo { get; set; }
/// <summary>
/// 国内承运商回执单号(揽收承运商运单号)。
/// </summary>
public string? CarrierWaybillNo { get; set; }
/// <summary>
/// 幂等请求标识。
/// </summary>

View File

@ -50,6 +50,11 @@ public class OrderParcelCreateOutput
/// </summary>
public int? PickupOrderId { get; set; }
/// <summary>
/// 国内承运运单号(上门揽收时可选)。
/// </summary>
public string? CarrierWaybillNo { get; set; }
/// <summary>
/// 揽收单创建失败原因(运单已成功时仅作提示)。
/// </summary>

View File

@ -38,3 +38,22 @@ public class OrderReallocateBatchInput
public int? ServiceChannelId { get; set; }
}
/// <summary>
/// 查询指定渠道可选项(按订单排货池交集)。
/// </summary>
public class OrderReallocateChannelOptionsInput
{
[Required]
[MinLength(1)]
public List<int> OrderIds { get; set; } = [];
}
public class OrderReallocateChannelOptionOutput
{
public int ServiceChannelId { get; set; }
public string ChannelCode { get; set; } = string.Empty;
public string? ChannelName { get; set; }
}

View File

@ -6,6 +6,11 @@ public class PickupOrderCreateInput
{
public int? CustomerId { get; set; }
/// <summary>
/// 请求幂等键(可选)。
/// </summary>
public string? RequestId { get; set; }
[Required]
public int SenderAddressId { get; set; }
@ -38,6 +43,39 @@ public class PickupOrderIdInput
public int Id { get; set; }
}
public class PickupOrderIdsInput
{
[Required]
[MinLength(1)]
public List<int> Ids { get; set; } = [];
}
public class PickupOrderBatchPayItemOutput
{
public int Id { get; set; }
public string? PickupNo { get; set; }
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public decimal? Amount { get; set; }
}
public class PickupOrderBatchPayOutput
{
public int SuccessCount { get; set; }
public int FailCount { get; set; }
public decimal PaidAmount { get; set; }
public string Message { get; set; } = string.Empty;
public List<PickupOrderBatchPayItemOutput> Items { get; set; } = [];
}
public class PickupOrderOrdersInput
{
[Required]
@ -85,10 +123,21 @@ public class PickupOrderScheduleInput
[Required]
public int Id { get; set; }
[Required]
public string CarrierCode { get; set; } = string.Empty;
/// <summary>
/// 兼容旧字段,已不再作为预约必填;承运服务改用销售产品编码。
/// </summary>
public string? CarrierCode { get; set; }
public string? CarrierService { get; set; }
/// <summary>
/// 承运服务:国内揽收分组销售产品编码。
/// </summary>
[Required]
public string CarrierService { get; set; } = string.Empty;
/// <summary>
/// 国内承运商回执单号(手工录入或对接返回)。
/// </summary>
public string? CarrierWaybillNo { get; set; }
public DateTime? ExpectPickupTime { get; set; }
}
@ -132,24 +181,46 @@ public class PickupOrderOutput
public string PickupNo { get; set; } = string.Empty;
public string? RequestId { get; set; }
public int CustomerId { get; set; }
public string? CustomerName { get; set; }
public string? OrganizationId { get; set; }
public int SecretLevel { get; set; }
public int SenderAddressId { get; set; }
public string SenderName { get; set; } = string.Empty;
public string? SenderPhone { get; set; }
public string? SenderTelePhone { get; set; }
public string? SenderEmail { get; set; }
public string? SenderCompany { get; set; }
public int? SenderCountry { get; set; }
public string? SenderCountryCode { get; set; }
public string? SenderProvince { get; set; }
public string? SenderCity { get; set; }
public string? SenderZipCode { get; set; }
public string? SenderStreetAddress1 { get; set; }
public string? SenderStreetAddress2 { get; set; }
public string? SenderStreetAddress3 { get; set; }
public string? SenderHouseNumber { get; set; }
public int WarehouseId { get; set; }
public string? WarehouseName { get; set; }

View File

@ -24,6 +24,9 @@ public class IssueTemplateService : ApplicationService
private DataPermRepository DataPermRepository =>
LazyServiceProvider.GetRequiredService<DataPermRepository>();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
/// <summary>
/// 分页查询问题模板列表。
/// </summary>
@ -40,7 +43,8 @@ public class IssueTemplateService : ApplicationService
new Dictionary<string, bool> { [nameof(IssueTemplate.UpdateTime)] = false, [nameof(IssueTemplate.Id)] = false });
var total = await IssueTemplateRepository.CountAsync(predicate);
var orgNameMap = await ResolveOrganizationNameMapAsync(entities);
var items = entities.Select(x => MapOutput(x, orgNameMap)).ToList();
var handlerUserNameMap = await ResolveHandlerUserNameMapAsync(entities);
var items = entities.Select(x => MapOutput(x, orgNameMap, handlerUserNameMap)).ToList();
return new OmsPageResult<IssueTemplateOutput> { Items = items, Total = total };
}
@ -52,7 +56,8 @@ public class IssueTemplateService : ApplicationService
{
var entity = await GetEntityOrThrowAsync(id);
var orgNameMap = await ResolveOrganizationNameMapAsync([entity]);
return MapOutput(entity, orgNameMap);
var handlerUserNameMap = await ResolveHandlerUserNameMapAsync([entity]);
return MapOutput(entity, orgNameMap, handlerUserNameMap);
}
/// <summary>
@ -69,6 +74,8 @@ public class IssueTemplateService : ApplicationService
Title = normalized.Title,
Content = normalized.Content,
NotifyMethod = normalized.NotifyMethod,
HandlerType = normalized.HandlerType,
HandlerUserId = normalized.HandlerUserId,
Status = normalized.Status
};
await IssueTemplateRepository.InsertAsync(entity);
@ -87,6 +94,8 @@ public class IssueTemplateService : ApplicationService
entity.Title = normalized.Title;
entity.Content = normalized.Content;
entity.NotifyMethod = normalized.NotifyMethod;
entity.HandlerType = normalized.HandlerType;
entity.HandlerUserId = normalized.HandlerUserId;
entity.Status = normalized.Status;
await IssueTemplateRepository.UpdateAsync(entity);
}
@ -212,6 +221,38 @@ public class IssueTemplateService : ApplicationService
throw new BusinessException("请至少选择一种通知方式");
}
var handlerType = input.HandlerType?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(handlerType))
{
handlerType = IssueHandlerTypeCodes.User;
}
if (!IssueHandlerTypeCodes.All.Contains(handlerType))
{
throw new BusinessException("处理人类型不合法");
}
int? handlerUserId = null;
if (IssueHandlerTypeCodes.IsUser(handlerType))
{
if (input.HandlerUserId is not int userId || userId <= 0)
{
throw new BusinessException("请选择指定处理人");
}
handlerUserId = userId;
}
else if (IssueHandlerTypeCodes.IsCustomer(handlerType))
{
var methods = IssueNotifyMethodCodes.ParseList(notifyMethod);
if (methods.Any(x => !string.Equals(x, IssueNotifyMethodCodes.InApp, StringComparison.OrdinalIgnoreCase)))
{
throw new BusinessException("处理人为客户时,目前仅支持站内信通知");
}
notifyMethod = IssueNotifyMethodCodes.InApp;
}
var organizationId = NormalizeOrganizationId(input.OrganizationId);
if (!string.IsNullOrWhiteSpace(organizationId))
{
@ -238,6 +279,8 @@ public class IssueTemplateService : ApplicationService
Title = title,
Content = content,
NotifyMethod = notifyMethod,
HandlerType = handlerType,
HandlerUserId = handlerUserId,
Status = input.Status
};
}
@ -280,9 +323,20 @@ public class IssueTemplateService : ApplicationService
: await DataPermRepository.GetOrganizationNamesAsync(orgIds);
}
private async Task<IReadOnlyDictionary<int, string>> ResolveHandlerUserNameMapAsync(
IReadOnlyCollection<IssueTemplate> entities)
{
var userIds = entities
.Where(x => IssueHandlerTypeCodes.IsUser(x.HandlerType) && x.HandlerUserId is > 0)
.Select(x => x.HandlerUserId!.Value)
.Distinct();
return await UserDisplayNameResolver.ResolveManyAsync(userIds);
}
private static IssueTemplateOutput MapOutput(
IssueTemplate entity,
IReadOnlyDictionary<Guid, string> orgNameMap)
IReadOnlyDictionary<Guid, string> orgNameMap,
IReadOnlyDictionary<int, string> handlerUserNameMap)
{
string? organizationName = null;
if (!string.IsNullOrWhiteSpace(entity.OrganizationId)
@ -307,6 +361,12 @@ public class IssueTemplateService : ApplicationService
Content = entity.Content,
NotifyMethod = entity.NotifyMethod,
NotifyMethods = IssueNotifyMethodCodes.ParseList(entity.NotifyMethod),
HandlerType = entity.HandlerType,
HandlerTypeName = IssueHandlerTypeCodes.GetDisplayName(entity.HandlerType),
HandlerUserId = entity.HandlerUserId,
HandlerUserName = entity.HandlerUserId is int handlerUserId && handlerUserId > 0
? OmsRbacUserDisplayNameResolver.FormatUserId(handlerUserId, handlerUserNameMap)
: null,
Status = (byte)entity.Status,
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
@ -325,6 +385,10 @@ public class IssueTemplateService : ApplicationService
public string NotifyMethod { get; set; } = string.Empty;
public string HandlerType { get; set; } = IssueHandlerTypeCodes.User;
public int? HandlerUserId { get; set; }
public EnableStatus Status { get; set; }
}
}

View File

@ -134,6 +134,7 @@ public class AutoTriggerRuntimeService(
IssuePlaceholderRenderer.Render(content, context),
IssueNotifyMethodCodes.InApp,
operatorUserId,
null,
AutoTriggerConditionMatcher.ParseNotifierUserIds(trigger.Notifier));
return;
}

View File

@ -0,0 +1,101 @@
using Fake.Application;
using Fake.DependencyInjection;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders.Issues;
public sealed class IssueHandlerResolution
{
public string? HandlerType { get; set; }
public int? HandlerUserId { get; set; }
public string? HandlerNames { get; set; }
public IReadOnlyList<int> NotifyUserIds { get; set; } = [];
}
public class IssueHandlerResolver(
ICustomerRepository customerRepository,
OmsRbacUserDisplayNameResolver userDisplayNameResolver) : IScopedDependency
{
public async Task<IssueHandlerResolution> ResolveAsync(
Order order,
IssueTemplate? template,
string? manualHandlerNames = null)
{
if (template != null && !string.IsNullOrWhiteSpace(template.HandlerType))
{
if (IssueHandlerTypeCodes.IsCustomer(template.HandlerType))
{
return await ResolveCustomerHandlerAsync(order);
}
if (IssueHandlerTypeCodes.IsUser(template.HandlerType))
{
return await ResolveUserHandlerAsync(template.HandlerUserId);
}
}
var names = manualHandlerNames?.Trim();
return new IssueHandlerResolution
{
HandlerNames = string.IsNullOrWhiteSpace(names) ? null : names,
NotifyUserIds = []
};
}
private async Task<IssueHandlerResolution> ResolveCustomerHandlerAsync(Order order)
{
if (order.CustomerId is not int customerId || customerId <= 0)
{
throw new BusinessException("订单未关联客户,无法指定客户为处理人");
}
var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customerId);
if (customer == null)
{
throw new BusinessException("订单客户不存在,无法指定客户为处理人");
}
if (customer.UserId <= 0)
{
throw new BusinessException("客户未绑定登录账号,无法通知客户处理");
}
var displayName = string.IsNullOrWhiteSpace(customer.CustomerName)
? $"客户#{customer.Id}"
: customer.CustomerName.Trim();
return new IssueHandlerResolution
{
HandlerType = IssueHandlerTypeCodes.Customer,
HandlerUserId = customer.UserId,
HandlerNames = displayName,
NotifyUserIds = [customer.UserId]
};
}
private async Task<IssueHandlerResolution> ResolveUserHandlerAsync(int? handlerUserId)
{
if (handlerUserId is not int userId || userId <= 0)
{
throw new BusinessException("问题模板未配置指定处理人");
}
var nameMap = await userDisplayNameResolver.ResolveManyAsync([userId]);
var displayName = OmsRbacUserDisplayNameResolver.FormatUserId(userId, nameMap);
return new IssueHandlerResolution
{
HandlerType = IssueHandlerTypeCodes.User,
HandlerUserId = userId,
HandlerNames = displayName,
NotifyUserIds = [userId]
};
}
}

View File

@ -1,3 +1,4 @@
using Fake.Application;
using Fake.DependencyInjection;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
@ -21,6 +22,8 @@ public sealed class ProblematicMarkRequest
public string? NotifyMethod { get; set; }
public string? HandlerNames { get; set; }
public string? Remark { get; set; }
public IEnumerable<int>? ExtraReceiverUserIds { get; set; }
@ -30,6 +33,7 @@ public class OrderExceptionWorkflowService(
IOrderRepository orderRepository,
IOrderExceptionEventRepository orderExceptionEventRepository,
IIssueTemplateRepository issueTemplateRepository,
IssueHandlerResolver issueHandlerResolver,
OrderIssueNotificationService issueNotificationService) : IScopedDependency
{
public async Task<string?> TryMarkProblematicAsync(
@ -43,13 +47,40 @@ public class OrderExceptionWorkflowService(
return OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus);
}
IssueTemplate? template = null;
if (request.IssueTemplateId is int templateId && templateId > 0)
{
template = await issueTemplateRepository.FirstOrDefaultAsync(x => x.Id == templateId);
}
IssueHandlerResolution handlerResolution;
try
{
handlerResolution = await issueHandlerResolver.ResolveAsync(order, template, request.HandlerNames);
}
catch (BusinessException ex)
{
return ex.Message;
}
var context = IssuePlaceholderContext.FromOrder(order, request.IssueType, request.Title);
var renderedTitle = IssuePlaceholderRenderer.Render(request.Title, context);
var renderedContent = IssuePlaceholderRenderer.Render(request.Content, context);
var notifyMethod = request.NotifyMethod;
if (IssueHandlerTypeCodes.IsCustomer(handlerResolution.HandlerType))
{
notifyMethod = IssueNotifyMethodCodes.InApp;
}
var fromStatus = order.OrderStatus;
order.PreviousOrderStatus = fromStatus;
order.OrderStatus = OrderStatusCodes.Problematic;
var isAppend = string.Equals(fromStatus, OrderStatusCodes.Problematic, StringComparison.OrdinalIgnoreCase);
if (!isAppend)
{
order.PreviousOrderStatus = fromStatus;
order.OrderStatus = OrderStatusCodes.Problematic;
}
order.HandleStatus = OrderHandleStatusCodes.Pending;
order.IssueType = request.IssueType;
order.IssueTitle = renderedTitle;
@ -76,18 +107,25 @@ public class OrderExceptionWorkflowService(
Title = renderedTitle,
Content = renderedContent,
IssueTemplateId = request.IssueTemplateId,
NotifyMethod = request.NotifyMethod,
NotifyMethod = notifyMethod,
HandlerNames = handlerResolution.HandlerNames,
HandlerType = handlerResolution.HandlerType,
HandlerUserId = handlerResolution.HandlerUserId,
Remark = request.Remark
});
if (sendNotification)
if (sendNotification && !string.IsNullOrWhiteSpace(notifyMethod))
{
var notifyUserIds = handlerResolution.NotifyUserIds.Count > 0
? handlerResolution.NotifyUserIds
: null;
await issueNotificationService.DispatchAsync(
order,
renderedTitle,
renderedContent,
request.NotifyMethod,
notifyMethod,
operatorUserId,
notifyUserIds,
request.ExtraReceiverUserIds);
}

View File

@ -29,6 +29,7 @@ public class OrderIssueNotificationService(
string content,
string? notifyMethodCsv,
int operatorUserId,
IEnumerable<int>? primaryReceiverUserIds = null,
IEnumerable<int>? extraReceiverUserIds = null)
{
var methods = IssueNotifyMethodCodes.ParseList(notifyMethodCsv);
@ -50,7 +51,7 @@ public class OrderIssueNotificationService(
switch (method)
{
case IssueNotifyMethodCodes.InApp:
await SendInAppAsync(order, customer, title, content, extraReceiverUserIds);
await SendInAppAsync(order, customer, title, content, primaryReceiverUserIds, extraReceiverUserIds);
break;
case IssueNotifyMethodCodes.Email:
await SendEmailAsync(order, customer, title, content, operatorUserId);
@ -81,19 +82,44 @@ public class OrderIssueNotificationService(
Customer? customer,
string title,
string content,
IEnumerable<int>? primaryReceiverUserIds,
IEnumerable<int>? extraReceiverUserIds)
{
var receiverIds = new HashSet<int>();
if (customer is { UserId: > 0 })
if (primaryReceiverUserIds != null)
{
receiverIds.Add(customer.UserId);
foreach (var userId in primaryReceiverUserIds)
{
if (userId > 0)
{
receiverIds.Add(userId);
}
}
}
foreach (var userId in extraReceiverUserIds ?? [])
if (receiverIds.Count == 0)
{
if (userId > 0)
if (customer is { UserId: > 0 })
{
receiverIds.Add(userId);
receiverIds.Add(customer.UserId);
}
foreach (var userId in extraReceiverUserIds ?? [])
{
if (userId > 0)
{
receiverIds.Add(userId);
}
}
}
else
{
foreach (var userId in extraReceiverUserIds ?? [])
{
if (userId > 0)
{
receiverIds.Add(userId);
}
}
}

View File

@ -3,6 +3,7 @@ using Fake.Domain.Exceptions;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders.Issues;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
@ -40,6 +41,7 @@ public partial class OrderService
Content = payload.Content,
IssueTemplateId = payload.IssueTemplateId,
NotifyMethod = payload.NotifyMethod,
HandlerNames = payload.HandlerNames,
Remark = payload.Remark
};
@ -115,6 +117,154 @@ public partial class OrderService
input.Remark?.Trim());
}
/// <summary>
/// 订单问题处理记录列表(mark_problematic 事件,支持一单多条)。
/// </summary>
[HttpGet("/order/exception-events/list")]
public async Task<OrderExceptionEventListOutput> GetExceptionEventListAsync([FromQuery] int orderId)
{
if (orderId <= 0)
{
throw new BusinessException("订单Id无效");
}
await GetOrderOrThrowAsync(orderId);
var events = await OrderExceptionEventRepository.GetListAsync(x =>
x.OrderId == orderId && x.EventType == OrderExceptionEventTypeCodes.MarkProblematic);
events = events.OrderByDescending(x => x.CreateTime).ThenByDescending(x => x.Id).ToList();
var userIds = events
.SelectMany(x => new int?[] { x.CreateUserId, x.UpdateUserId, x.HandlerUserId })
.Where(x => x is > 0)
.Select(x => x!.Value)
.Distinct();
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(userIds);
var items = events.Select(evt => MapExceptionEventOutput(evt, auditUserNameMap)).ToList();
return new OrderExceptionEventListOutput
{
Items = items,
PendingCount = items.Count(x => OrderHandleStatusCodes.IsPending(x.HandleStatus)),
ProcessedCount = items.Count(x => OrderHandleStatusCodes.IsProcessed(x.HandleStatus))
};
}
/// <summary>
/// 单条问题标记已处理。
/// </summary>
[HttpPost("/order/exception-event/process")]
public async Task ProcessExceptionEventAsync([FromBody] OrderExceptionEventProcessInput input)
{
if (input.Id <= 0)
{
throw new BusinessException("问题记录Id无效");
}
var evt = await OrderExceptionEventRepository.FirstOrDefaultAsync(x => x.Id == input.Id);
if (evt == null || !string.Equals(evt.EventType, OrderExceptionEventTypeCodes.MarkProblematic, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("问题记录不存在");
}
await GetOrderOrThrowAsync(evt.OrderId);
if (OrderHandleStatusCodes.IsProcessed(evt.ToHandleStatus))
{
throw new BusinessException("该问题已处理");
}
var remark = input.Remark?.Trim();
if (!string.IsNullOrWhiteSpace(remark) && remark.Length > 512)
{
throw new BusinessException("备注长度不能超过512");
}
var operatorUserId = CurrentUser.GetId<int>();
evt.ToHandleStatus = OrderHandleStatusCodes.Processed;
evt.UpdateUserId = operatorUserId;
evt.UpdateTime = DateTime.Now;
if (!string.IsNullOrWhiteSpace(remark))
{
evt.Remark = remark;
}
await OrderExceptionEventRepository.UpdateAsync(evt);
await OrderExceptionEventRepository.InsertAsync(new OrderExceptionEvent
{
OrderId = evt.OrderId,
EventType = OrderExceptionEventTypeCodes.HandleProcessed,
FromStatus = OrderStatusCodes.Problematic,
ToStatus = OrderStatusCodes.Problematic,
FromHandleStatus = OrderHandleStatusCodes.Pending,
ToHandleStatus = OrderHandleStatusCodes.Processed,
IssueType = evt.IssueType,
Title = evt.Title,
Content = evt.Content,
Remark = remark
});
await SyncOrderHandleStatusFromEventsAsync(evt.OrderId);
}
private async Task SyncOrderHandleStatusFromEventsAsync(int orderId)
{
var order = await GetOrderOrThrowAsync(orderId);
if (order.OrderStatus is not (OrderStatusCodes.Problematic or OrderStatusCodes.Claimed))
{
return;
}
var issues = await OrderExceptionEventRepository.GetListAsync(x =>
x.OrderId == orderId && x.EventType == OrderExceptionEventTypeCodes.MarkProblematic);
if (issues.Count == 0)
{
return;
}
var anyPending = issues.Any(x => OrderHandleStatusCodes.IsPending(x.ToHandleStatus));
order.HandleStatus = anyPending ? OrderHandleStatusCodes.Pending : OrderHandleStatusCodes.Processed;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
private static OrderExceptionEventOutput MapExceptionEventOutput(
OrderExceptionEvent evt,
IReadOnlyDictionary<int, string> auditUserNameMap)
{
var notifyCodes = IssueNotifyMethodCodes.ParseList(evt.NotifyMethod);
var notifyNames = notifyCodes.Count == 0
? string.Empty
: string.Join('、', notifyCodes.Select(IssueNotifyMethodCodes.GetDisplayName));
return new OrderExceptionEventOutput
{
Id = evt.Id,
OrderId = evt.OrderId,
EventType = evt.EventType,
IssueType = evt.IssueType,
IssueTypeName = IssueTypeCodes.GetDisplayName(evt.IssueType),
Title = evt.Title,
Content = evt.Content,
NotifyMethod = evt.NotifyMethod,
NotifyMethodNames = notifyNames,
HandlerNames = evt.HandlerNames
?? (evt.HandlerUserId is int handlerUserId && handlerUserId > 0
? OmsRbacUserDisplayNameResolver.FormatUserId(handlerUserId, auditUserNameMap)
: null),
HandlerType = evt.HandlerType,
HandlerTypeName = IssueHandlerTypeCodes.GetDisplayName(evt.HandlerType),
HandlerUserId = evt.HandlerUserId,
HandleStatus = evt.ToHandleStatus,
HandleStatusName = OrderHandleStatusCodes.GetDisplayName(evt.ToHandleStatus),
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(evt.CreateUserId, auditUserNameMap),
CreateTime = evt.CreateTime,
ProcessedBy = OrderHandleStatusCodes.IsProcessed(evt.ToHandleStatus)
? OmsRbacUserDisplayNameResolver.FormatUserId(evt.UpdateUserId, auditUserNameMap)
: null,
ProcessedTime = OrderHandleStatusCodes.IsProcessed(evt.ToHandleStatus) ? evt.UpdateTime : null,
Remark = evt.Remark
};
}
private async Task<OrderBatchActionResultOutput> ExecuteProblematicBatchAsync(
IReadOnlyCollection<int> orderIds,
ProblematicMarkRequest request)
@ -271,6 +421,12 @@ public partial class OrderService
throw new BusinessException("备注长度不能超过512");
}
var handlerNames = input.HandlerNames?.Trim();
if (!string.IsNullOrWhiteSpace(handlerNames) && handlerNames.Length > 256)
{
throw new BusinessException("处理人长度不能超过256");
}
return new ResolvedProblematicPayload
{
IssueType = issueType,
@ -278,6 +434,7 @@ public partial class OrderService
Content = content,
IssueTemplateId = template?.Id,
NotifyMethod = template == null ? null : IssueNotifyMethodCodes.Normalize(template.NotifyMethod),
HandlerNames = string.IsNullOrWhiteSpace(handlerNames) ? null : handlerNames,
Remark = remark
};
}
@ -399,6 +556,8 @@ public partial class OrderService
public string? NotifyMethod { get; set; }
public string? HandlerNames { get; set; }
public string? Remark { get; set; }
}

View File

@ -98,6 +98,9 @@ public partial class OrderService
? []
: await PickupOrderRepository.GetListAsync(x => pickupOrderIds.Contains(x.Id));
var pickupNoMap = pickupOrders.ToDictionary(x => x.Id, x => x.PickupNo);
var carrierWaybillNoMap = pickupOrders
.Where(x => !string.IsNullOrWhiteSpace(x.CarrierWaybillNo))
.ToDictionary(x => x.Id, x => x.CarrierWaybillNo!);
var outputs = orders.Select(order =>
{
@ -165,6 +168,10 @@ public partial class OrderService
&& pickupNoMap.TryGetValue(order.PickupOrderId.Value, out var pickupNo)
? pickupNo
: null,
CarrierWaybillNo = order.PickupOrderId is > 0
&& carrierWaybillNoMap.TryGetValue(order.PickupOrderId.Value, out var carrierWaybillNo)
? carrierWaybillNo
: null,
RequestId = order.RequestId,
ExportCustomsMode = order.ExportCustomsMode,
PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason)

View File

@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
@ -328,7 +329,8 @@ public partial class OrderService
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
var weightRow = ResolvePayableWeightRow(order, weights);
var chargeable = weightRow.ChargeableWeight ?? 0;
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
// 现算与重新排货一致:规则分区 + 渠道买价均取当前有效发布。
var snapshotBinding = await ResolveSnapshotBindingAsync(product, DateTime.Now);
var billing = await ResolveOrderBillingContextAsync(
order,
product,
@ -393,6 +395,91 @@ public partial class OrderService
}
}
/// <summary>
/// 指定渠道可选项:取选中订单销售产品排货池(含客户过滤)的交集。
/// </summary>
[HttpPost("/order/fee/reallocate-channel-options")]
public async Task<OmsItemsResult<OrderReallocateChannelOptionOutput>> GetReallocateChannelOptionsAsync(
[FromBody] OrderReallocateChannelOptionsInput input)
{
var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList();
if (orderIds.Count == 0)
{
return new OmsItemsResult<OrderReallocateChannelOptionOutput> { Items = [] };
}
var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
if (orders.Count == 0)
{
return new OmsItemsResult<OrderReallocateChannelOptionOutput> { Items = [] };
}
HashSet<int>? intersection = null;
var channelMeta = new Dictionary<int, (string Code, string? Name)>();
foreach (var order in orders)
{
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
return new OmsItemsResult<OrderReallocateChannelOptionOutput> { Items = [] };
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
return new OmsItemsResult<OrderReallocateChannelOptionOutput> { Items = [] };
}
var binding = await ResolveSnapshotBindingAsync(product, DateTime.Now);
var providers = await ResolveAllocationProvidersAsync(product, binding.Rules);
var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId);
var channelIds = new HashSet<int>();
foreach (var provider in filtered)
{
if (provider.ServiceChannelId is not > 0)
{
continue;
}
var channelId = provider.ServiceChannelId.Value;
if (!channelMeta.TryGetValue(channelId, out _))
{
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
if (channel == null || channel.IsActive != EnableStatus.Enabled)
{
continue;
}
channelMeta[channelId] = (channel.ChannelCode, channel.ChannelName);
}
channelIds.Add(channelId);
}
intersection = intersection == null
? channelIds
: intersection.Intersect(channelIds).ToHashSet();
if (intersection.Count == 0)
{
break;
}
}
var items = (intersection ?? [])
.Where(id => channelMeta.ContainsKey(id))
.Select(id => new OrderReallocateChannelOptionOutput
{
ServiceChannelId = id,
ChannelCode = channelMeta[id].Code,
ChannelName = channelMeta[id].Name
})
.OrderBy(x => x.ChannelCode, StringComparer.OrdinalIgnoreCase)
.ToList();
return new OmsItemsResult<OrderReallocateChannelOptionOutput> { Items = items };
}
/// <summary>
/// 单票重新排货:仅重写应付与服务渠道,不动应收。
/// </summary>
@ -646,7 +733,10 @@ public partial class OrderService
throw new BusinessException("计费重无效,请先完善件重尺");
}
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
// 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。
var snapshotBinding = enforceReallocateGate
? await ResolveSnapshotBindingAsync(product, DateTime.Now)
: await ResolveSnapshotBindingFromOrderAsync(product, order);
var billing = await ResolveOrderBillingContextAsync(
order,
product,
@ -787,7 +877,7 @@ public partial class OrderService
private static (string WeightType, string ChargingStage) ResolveReceivableBillingStage(Order order) =>
order.OrderStatus switch
{
OrderStatusCodes.Received or OrderStatusCodes.PendingAllocation or OrderStatusCodes.PendingPayment or OrderStatusCodes.PendingShip
OrderStatusCodes.PendingAllocation or OrderStatusCodes.PendingPayment or OrderStatusCodes.PendingShip
=> (OrderWeightTypeCodes.Receive, OrderFeeChargingStageCodes.Inbound),
OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed

View File

@ -793,11 +793,28 @@ public partial class OrderService
throw new BusinessException(evaluation.FailReason);
}
var previousChannelCode = order.ChannelCode?.Trim();
var newChannelCode = selectedCandidate.Channel.ChannelCode?.Trim() ?? string.Empty;
var channelChanged = !string.Equals(
previousChannelCode,
newChannelCode,
StringComparison.OrdinalIgnoreCase);
order.ChannelCode = selectedCandidate.Channel.ChannelCode;
AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder(
order,
selectedCandidate.Provider,
selectedCandidate.Channel);
// 换渠道后旧下游单号/跟踪号失效,清空以免「新渠道+旧服务商单号」。
if (channelChanged
&& (!string.IsNullOrWhiteSpace(order.DownstreamServiceNo)
|| !string.IsNullOrWhiteSpace(order.TrackNo)))
{
order.DownstreamServiceNo = null;
order.TrackNo = null;
}
// 有效应付只保留本阶段一套,避免 forecast+inbound 叠行虚增成本。
await OrderRepository.DeletePayableFeesAsync(order.Id);
await OrderRepository.UpsertFeesAsync(order.Id, payableFees);
@ -811,6 +828,7 @@ public partial class OrderService
public string SchemeSource { get; init; } = "productDefault";
public decimal ChargeableWeight { get; init; }
public string ChargingStage { get; init; } = string.Empty;
public IReadOnlyList<string> ZoneKeys { get; init; } = [];
public List<AllocationResolver.ChannelCandidate> Candidates { get; init; } = [];
public List<OrderAllocationSkippedOutput> Skipped { get; init; } = [];
public AllocationResolver.ChannelCandidate? Selected { get; set; }
@ -872,6 +890,7 @@ public partial class OrderService
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Skipped = skipped,
FailReason = "指定渠道不在该销售产品排货池或当前客户不可用"
};
@ -887,14 +906,17 @@ public partial class OrderService
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Skipped = skipped,
FailReason = "排货规则未匹配到可用服务渠道"
};
}
var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime;
// 应付买价取「当前」有效发布(PublishTime<=Now),不用下单时间,避免重新排货/入库漏掉后续发布的买价。
var bindTime = DateTime.Now;
var candidates = new List<AllocationResolver.ChannelCandidate>();
var recipient = (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault();
var cargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General;
foreach (var provider in filtered)
{
@ -942,7 +964,7 @@ public partial class OrderService
channelPriceBinding.PriceEffectiveTime,
chargeableWeight,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
cargoAttribute,
chargingStage,
order.WarehouseId,
warehouseName,
@ -952,12 +974,22 @@ public partial class OrderService
channelRemoteZoneKeys);
if (previewFees.Count == 0)
{
var miss = ParcelBillingCalculator.ExplainPayablePriceMiss(
channelPriceBinding.Prices,
chargeableWeight,
zoneKeys,
cargoAttribute,
order.WarehouseId,
warehouseName,
channelRemoteZoneKeys);
skipped.Add(new OrderAllocationSkippedOutput
{
ServiceChannelId = channel.Id,
ChannelCode = channel.ChannelCode,
ChannelName = channel.ChannelName,
Reason = "未匹配到有效渠道买价"
Reason = string.IsNullOrWhiteSpace(miss)
? $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion})"
: $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion}):{miss}"
});
continue;
}
@ -973,6 +1005,7 @@ public partial class OrderService
if (candidates.Count == 0)
{
var detail = skipped.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Reason))?.Reason;
return new AllocationEvaluation
{
StoredScheme = storedScheme,
@ -980,11 +1013,14 @@ public partial class OrderService
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Candidates = candidates,
Skipped = skipped,
FailReason = chargeableWeight <= 0
? "计费重无效,无法计算应付费用"
: "排货规则未匹配到有效渠道买价"
: string.IsNullOrWhiteSpace(detail)
? "排货规则未匹配到有效渠道买价"
: $"排货规则未匹配到有效渠道买价;{detail}"
};
}
@ -996,6 +1032,7 @@ public partial class OrderService
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Candidates = candidates,
Skipped = skipped,
Selected = selected
@ -1053,6 +1090,7 @@ public partial class OrderService
SchemeSource = evaluation.SchemeSource,
SchemeSourceName = ResolveAllocationSchemeSourceName(evaluation.SchemeSource),
ChargeableWeight = evaluation.ChargeableWeight,
ZoneKeys = evaluation.ZoneKeys?.ToList() ?? [],
ChargingStage = evaluation.ChargingStage,
ChargingStageName = ResolveChargingStageName(evaluation.ChargingStage),
SelectedChannelCode = selectedChannelCode ?? evaluation.Selected?.Channel.ChannelCode,

View File

@ -125,7 +125,7 @@ public partial class OrderService : ApplicationService
throw new BusinessException("订单已支付");
}
if (order.OrderStatus is not (OrderStatusCodes.PendingPayment or OrderStatusCodes.Received))
if (order.OrderStatus is not OrderStatusCodes.PendingPayment)
{
throw new BusinessException("当前订单状态不可支付");
}
@ -475,6 +475,7 @@ public partial class OrderService : ApplicationService
});
output.PickupNo = pickup.PickupNo;
output.PickupOrderId = pickup.Id;
output.CarrierWaybillNo = pickup.CarrierWaybillNo;
}
catch (Exception ex)
{

View File

@ -628,6 +628,128 @@ internal static class ParcelBillingCalculator
return string.Join(";", reasons);
}
/// <summary>
/// 买价未命中时,按价格项输出具体原因(分区/货物类型/重量区间等)。
/// </summary>
public static string ExplainPayablePriceMiss(
IReadOnlyList<ServiceChannelPriceListOutput> prices,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
int? warehouseId = null,
string? warehouseName = null,
IReadOnlyList<string>? remoteZoneKeys = null)
{
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
var warehouseLabel = FormatWarehouseLabel(warehouseId, warehouseName);
var enabledPrices = prices.Where(x => EnableStatusRules.IsEnabled(x.Status)).ToList();
if (enabledPrices.Count == 0)
{
return "买价中无启用的价格项";
}
var reasons = new List<string>();
foreach (var price in enabledPrices)
{
var label = !string.IsNullOrWhiteSpace(price.PriceName) ? price.PriceName! : price.CostItem;
var details = price.Details ?? [];
if (details.Count == 0)
{
reasons.Add($"【{label}】价格项Id={price.Id}:无明细行");
continue;
}
var zoneMatchedDetails = details.Where(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return ZoneMatches(factors, zoneKeys);
}).ToList();
if (zoneMatchedDetails.Count == 0)
{
var detailZones = details
.Select(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return TryGetZoneFactorText(factors, out var zone) ? zone.Trim() : null;
})
.Where(zone => !string.IsNullOrWhiteSpace(zone))
.Distinct(StringComparer.OrdinalIgnoreCase);
reasons.Add(
$"【{label}】分区不匹配,明细分区=[{string.Join(", ", detailZones)}]," +
$"订单分区键=[{string.Join(", ", zoneKeys)}]");
continue;
}
var cargoMatchedDetails = zoneMatchedDetails.Where(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return MatchCargoTypeFactor(factors, cargoAttribute);
}).ToList();
if (cargoMatchedDetails.Count == 0)
{
reasons.Add($"【{label}】货物类型不匹配,当前={cargoLabel}");
continue;
}
var warehouseMatchedDetails = cargoMatchedDetails.Where(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return MatchWarehouseFactor(factors, warehouseId, warehouseName);
}).ToList();
if (warehouseMatchedDetails.Count == 0)
{
reasons.Add($"【{label}】仓库不匹配,当前={warehouseLabel}");
continue;
}
var remoteMatchedDetails = warehouseMatchedDetails.Where(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return RemoteZoneMatches(factors, remoteZoneKeys);
}).ToList();
if (remoteMatchedDetails.Count == 0)
{
reasons.Add($"【{label}】偏远分区不匹配");
continue;
}
var weightMatched = false;
var intervals = new List<string>();
foreach (var detail in remoteMatchedDetails)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (!TryGetWeightFactorText(factors, out var weightText))
{
weightMatched = true;
break;
}
intervals.Add(weightText);
if (TryParseWeightInterval(weightText, out var rangeStart, out var rangeEnd)
&& WeightInLeftOpenRightClosedInterval(chargeableWeight, rangeStart, rangeEnd))
{
weightMatched = true;
break;
}
}
if (!weightMatched)
{
var intervalText = intervals.Count > 0
? string.Join(", ", intervals.Distinct(StringComparer.OrdinalIgnoreCase))
: "无有效重量区间";
reasons.Add(
$"【{label}】计费重 {chargeableWeight}kg 未落入重量区间 [{intervalText}]");
}
}
return string.Join(";", reasons);
}
public static List<OrderFee> CalculateForecastReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,

View File

@ -156,6 +156,20 @@ public class PickupOrderService : ApplicationService
senderCountryCode = country?.CountryCode;
}
if (!string.IsNullOrWhiteSpace(input.RequestId))
{
var existedByRequest = await PickupOrderRepository.FirstOrDefaultAsync(x => x.RequestId == input.RequestId.Trim());
if (existedByRequest != null)
{
if (existedByRequest.CustomerId != customerId)
{
throw new BusinessException("幂等请求已存在");
}
return await MapOutputAsync(existedByRequest, includeOrders: true);
}
}
var weights = await OrderRepository.GetWeightsByOrderIdsAsync(accepted.Select(x => x.Id));
var weightSum = weights
.GroupBy(x => x.OrderId)
@ -165,21 +179,39 @@ public class PickupOrderService : ApplicationService
var freight = input.FreightAmount;
var receivable = freight;
var orgId = accepted
.Select(x => x.OrganizationId)
.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
var secretLevel = accepted
.Select(x => x.SecretLevel)
.FirstOrDefault(x => x > 0);
if (secretLevel <= 0)
{
secretLevel = 2;
}
var entity = new PickupOrder
{
PickupNo = await GeneratePickupNoAsync(),
RequestId = NormalizeOptional(input.RequestId),
CustomerId = customerId,
OrganizationId = orgId,
SecretLevel = secretLevel,
SenderAddressId = senderAddress.Id,
SenderName = senderAddress.SenderName ?? string.Empty,
SenderPhone = senderAddress.Phone,
SenderCompany = senderAddress.Company,
SenderCountry = senderAddress.Country,
SenderCountryCode = senderCountryCode,
SenderProvince = senderAddress.Province,
SenderCity = senderAddress.City,
SenderZipCode = senderAddress.ZipCode,
SenderStreetAddress1 = senderAddress.StreetAddress1 ?? string.Empty,
SenderStreetAddress2 = senderAddress.StreetAddress2,
SenderStreetAddress3 = senderAddress.StreetAddress3,
SenderHouseNumber = senderAddress.HouseNumber,
SenderPhone = senderAddress.Phone,
SenderTelePhone = senderAddress.TelePhone,
SenderEmail = senderAddress.Email,
WarehouseId = input.WarehouseId,
CarrierCode = NormalizeOptional(input.CarrierCode),
CarrierService = NormalizeOptional(input.CarrierService),
@ -206,6 +238,8 @@ public class PickupOrderService : ApplicationService
{
PickupOrderId = entity.Id,
OrderId = order.Id,
ServiceNo = order.ServiceNo,
ClientNo = order.ClientNo,
SortNo = sort++
});
@ -287,6 +321,8 @@ public class PickupOrderService : ApplicationService
{
PickupOrderId = pickup.Id,
OrderId = order.Id,
ServiceNo = order.ServiceNo,
ClientNo = order.ClientNo,
SortNo = sort++
});
order.PickupOrderId = pickup.Id;
@ -451,7 +487,66 @@ public class PickupOrderService : ApplicationService
[HttpPost("/order/pickup-order/pay")]
public async Task<PickupOrderOutput> PayAsync([FromBody] PickupOrderIdInput input)
{
var pickup = await GetOwnedPickupOrThrowAsync(input.Id);
var pickup = await PayCoreAsync(input.Id);
return await MapOutputAsync(pickup, includeOrders: true);
}
/// <summary>
/// 批量支付揽收运费(逐单扣余额,部分失败不回滚已成功项)。
/// </summary>
[HttpPost("/order/pickup-order/pay-batch")]
public async Task<PickupOrderBatchPayOutput> PayBatchAsync([FromBody] PickupOrderIdsInput input)
{
var ids = (input.Ids ?? []).Where(x => x > 0).Distinct().ToList();
if (ids.Count == 0)
{
throw new BusinessException("请至少选择一张揽收单");
}
if (ids.Count > 100)
{
throw new BusinessException("单次最多支付 100 张揽收单");
}
var output = new PickupOrderBatchPayOutput();
foreach (var id in ids)
{
try
{
var pickup = await PayCoreAsync(id);
var amount = pickup.ReceivableAmount ?? pickup.FreightAmount ?? 0;
output.SuccessCount++;
output.PaidAmount += amount;
output.Items.Add(new PickupOrderBatchPayItemOutput
{
Id = pickup.Id,
PickupNo = pickup.PickupNo,
Success = true,
Message = "支付成功",
Amount = amount
});
}
catch (Exception ex)
{
output.FailCount++;
var pickupNo = await TryGetPickupNoAsync(id);
output.Items.Add(new PickupOrderBatchPayItemOutput
{
Id = id,
PickupNo = pickupNo,
Success = false,
Message = ex.Message
});
}
}
output.Message = $"支付完成:成功 {output.SuccessCount},失败 {output.FailCount}";
return output;
}
private async Task<PickupOrder> PayCoreAsync(int id)
{
var pickup = await GetOwnedPickupOrThrowAsync(id);
if (pickup.PayStatus == PickupPayStatusCodes.Paid)
{
throw new BusinessException("揽收单已支付");
@ -485,7 +580,13 @@ public class PickupOrderService : ApplicationService
pickup.PayStatus = PickupPayStatusCodes.Paid;
pickup.PayTime = DateTime.Now;
await PickupOrderRepository.UpdateAsync(pickup);
return await MapOutputAsync(pickup, includeOrders: true);
return pickup;
}
private async Task<string?> TryGetPickupNoAsync(int id)
{
var pickup = await PickupOrderRepository.FirstOrDefaultAsync(x => x.Id == id);
return pickup?.PickupNo;
}
/// <summary>
@ -500,10 +601,10 @@ public class PickupOrderService : ApplicationService
throw new BusinessException("当前状态不可预约下发");
}
var carrier = NormalizeOptional(input.CarrierCode);
if (carrier == null)
var carrierService = NormalizeOptional(input.CarrierService);
if (carrierService == null)
{
throw new BusinessException("承运商不能为空");
throw new BusinessException("承运服务不能为空");
}
if (string.Equals(pickup.PayMode, PickupPayModeCodes.Prepaid, StringComparison.OrdinalIgnoreCase)
@ -518,8 +619,14 @@ public class PickupOrderService : ApplicationService
throw new BusinessException("请先确认揽收运费");
}
pickup.CarrierCode = carrier;
pickup.CarrierService = NormalizeOptional(input.CarrierService);
pickup.CarrierService = carrierService;
pickup.CarrierCode = NormalizeOptional(input.CarrierCode);
var carrierWaybillNo = NormalizeOptional(input.CarrierWaybillNo);
if (carrierWaybillNo != null)
{
pickup.CarrierWaybillNo = carrierWaybillNo;
}
if (input.ExpectPickupTime.HasValue && input.ExpectPickupTime.Value != default)
{
pickup.ExpectPickupTime = input.ExpectPickupTime.Value;
@ -781,15 +888,26 @@ public class PickupOrderService : ApplicationService
{
Id = entity.Id,
PickupNo = entity.PickupNo,
RequestId = entity.RequestId,
CustomerId = entity.CustomerId,
CustomerName = customerName,
OrganizationId = entity.OrganizationId,
SecretLevel = entity.SecretLevel,
SenderAddressId = entity.SenderAddressId,
SenderName = entity.SenderName,
SenderPhone = entity.SenderPhone,
SenderTelePhone = entity.SenderTelePhone,
SenderEmail = entity.SenderEmail,
SenderCompany = entity.SenderCompany,
SenderCountry = entity.SenderCountry,
SenderCountryCode = entity.SenderCountryCode,
SenderProvince = entity.SenderProvince,
SenderCity = entity.SenderCity,
SenderZipCode = entity.SenderZipCode,
SenderStreetAddress1 = entity.SenderStreetAddress1,
SenderStreetAddress2 = entity.SenderStreetAddress2,
SenderStreetAddress3 = entity.SenderStreetAddress3,
SenderHouseNumber = entity.SenderHouseNumber,
WarehouseId = entity.WarehouseId,
WarehouseName = warehouseName,
CarrierCode = entity.CarrierCode,

View File

@ -199,7 +199,6 @@ public static class OmsDocHelper
{
OrderStatusCodes.Draft => "草稿",
OrderStatusCodes.Predeclared => "已预报",
OrderStatusCodes.Received => "已收货",
OrderStatusCodes.PendingAllocation => "待排货",
OrderStatusCodes.PendingPayment => "待支付",
OrderStatusCodes.PendingShip => "待发货",

View File

@ -0,0 +1,27 @@
namespace Oms.Domain.ChannelsAggregate.Consts;
public static class IssueHandlerTypeCodes
{
public const string Customer = "customer";
public const string User = "user";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{
Customer,
User
};
public static string GetDisplayName(string? code) => code?.Trim().ToLowerInvariant() switch
{
Customer => "客户",
User => "指定用户",
_ => "—"
};
public static bool IsCustomer(string? code) =>
string.Equals(code?.Trim(), Customer, StringComparison.OrdinalIgnoreCase);
public static bool IsUser(string? code) =>
string.Equals(code?.Trim(), User, StringComparison.OrdinalIgnoreCase);
}

View File

@ -17,5 +17,11 @@ public class IssueTemplate : OmsAuditedAggregateRoot
public string NotifyMethod { get; set; } = string.Empty;
/// <summary>处理人类型:customer=订单所属客户,user=指定用户。</summary>
public string? HandlerType { get; set; }
/// <summary>指定处理人用户 Id(HandlerType=user 时)。</summary>
public int? HandlerUserId { get; set; }
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
}

View File

@ -6,7 +6,6 @@ public static class OrderExceptionStatusRules
{
OrderStatusCodes.Draft,
OrderStatusCodes.Deleted,
OrderStatusCodes.Problematic,
OrderStatusCodes.Claimed
};
@ -18,7 +17,6 @@ public static class OrderExceptionStatusRules
{
OrderStatusCodes.Draft => "草稿订单不可标记",
OrderStatusCodes.Deleted => "已删除订单不可标记",
OrderStatusCodes.Problematic => "已是问题件",
OrderStatusCodes.Claimed => "已是理赔件",
_ => "订单状态不允许标记"
};

View File

@ -4,7 +4,6 @@ public static class OrderStatusCodes
{
public const string Draft = "draft";
public const string Predeclared = "predeclared";
public const string Received = "received";
public const string PendingAllocation = "pendingAllocation";
public const string PendingPayment = "pendingPayment";
public const string PendingShip = "pendingShip";

View File

@ -28,6 +28,12 @@ public class OrderExceptionEvent : OmsAuditedAggregateRoot
public string? NotifyMethod { get; set; }
public string? HandlerNames { get; set; }
public string? HandlerType { get; set; }
public int? HandlerUserId { get; set; }
public string? ClaimType { get; set; }
public decimal? ClaimAmount { get; set; }

View File

@ -12,19 +12,27 @@ public class PickupOrder : OmsAuditedAggregateRoot
[SugarColumn(Length = 32)]
public string PickupNo { get; set; } = string.Empty;
[SugarColumn(Length = 64, IsNullable = true)]
public string? RequestId { get; set; }
public int CustomerId { get; set; }
[SugarColumn(Length = 36, IsNullable = true)]
public string? OrganizationId { get; set; }
public int SecretLevel { get; set; } = 2;
public int SenderAddressId { get; set; }
[SugarColumn(Length = 50)]
public string SenderName { get; set; } = string.Empty;
[SugarColumn(Length = 32, IsNullable = true)]
public string? SenderPhone { get; set; }
[SugarColumn(Length = 255, IsNullable = true)]
public string? SenderCompany { get; set; }
[SugarColumn(IsNullable = true)]
public int? SenderCountry { get; set; }
[SugarColumn(Length = 8, IsNullable = true)]
public string? SenderCountryCode { get; set; }
@ -43,6 +51,21 @@ public class PickupOrder : OmsAuditedAggregateRoot
[SugarColumn(Length = 500, IsNullable = true)]
public string? SenderStreetAddress2 { get; set; }
[SugarColumn(Length = 500, IsNullable = true)]
public string? SenderStreetAddress3 { get; set; }
[SugarColumn(Length = 50, IsNullable = true)]
public string? SenderHouseNumber { get; set; }
[SugarColumn(Length = 50, IsNullable = true)]
public string? SenderPhone { get; set; }
[SugarColumn(Length = 50, IsNullable = true)]
public string? SenderTelePhone { get; set; }
[SugarColumn(Length = 60, IsNullable = true)]
public string? SenderEmail { get; set; }
public int WarehouseId { get; set; }
[SugarColumn(Length = 64, IsNullable = true)]

View File

@ -13,5 +13,11 @@ public class PickupOrderLine : OmsAuditedAggregateRoot
public int OrderId { get; set; }
[SugarColumn(Length = 64, IsNullable = true)]
public string? ServiceNo { get; set; }
[SugarColumn(Length = 64, IsNullable = true)]
public string? ClientNo { get; set; }
public int SortNo { get; set; }
}