Merge remote-tracking branch 'prod/test' into test
All checks were successful
Build and Deploy / deploy (push) Successful in 1m5s

This commit is contained in:
xiaolipro 2026-10-19 15:08:16 +08:00
commit ce75fca721
27 changed files with 1982 additions and 128 deletions

View File

@ -158,6 +158,8 @@ public async Task<PlatformStoreOrderBatchCreateLogisticsOutput> BatchCreateFulfi
存量 DDL、字典初始化等脚本放在 `sql/`(说明见 `sql/README.md`),与运行时 ORM 查询分离;**运行时查询与写入必须 ORM**。 存量 DDL、字典初始化等脚本放在 `sql/`(说明见 `sql/README.md`),与运行时 ORM 查询分离;**运行时查询与写入必须 ORM**。
新增增量迁移文件命名:`migrate_<主题>_YYYYMMDD.sql`(日期在末尾,例如 `migrate_xxx_20261016.sql`)。详细约定见 `sql/README.md`。
### 3. 上传代码前先同步远端 ### 3. 上传代码前先同步远端
`test`、`master` 等共享分支会有其他人提交。**推送前必须先拉取远端,再上传**,避免覆盖或漏掉别人的提交。 `test`、`master` 等共享分支会有其他人提交。**推送前必须先拉取远端,再上传**,避免覆盖或漏掉别人的提交。

View File

@ -14,4 +14,6 @@ OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。
- 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行) - 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行)
- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过) - 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过)
- 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落 - 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落
- 单次增量迁移命名:`migrate_<主题>.sql`,尽量可重复执行(先判断列/表/字典是否已存在) - 单次增量迁移命名:`migrate_<主题>_YYYYMMDD.sql`(日期放在文件名**末尾**,如 `migrate_sales_store_order_provider_fulfillment_20261016.sql`)
- 尽量可重复执行(先判断列/表/字典是否已存在)
- **后续新增脚本一律带日期后缀**;历史未带日期的脚本不强制改名

View File

@ -0,0 +1,89 @@
-- 店铺订单:落库 shipping_provider / fulfillment_type;删除多面单、自定义标签、拒发字段。
-- 可重复执行。
SET @db := DATABASE();
-- t_sales_store_order.ShippingProvider
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_sales_store_order'
AND COLUMN_NAME = 'ShippingProvider'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_sales_store_order` ADD COLUMN `ShippingProvider` VARCHAR(128) NULL COMMENT ''平台物流商'' AFTER `ShippingLabelUrl`',
'SELECT ''t_sales_store_order.ShippingProvider already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- t_sales_store_order.FulfillmentType
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_sales_store_order'
AND COLUMN_NAME = 'FulfillmentType'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_sales_store_order` ADD COLUMN `FulfillmentType` VARCHAR(64) NULL COMMENT ''平台履约类型'' AFTER `ShippingProvider`',
'SELECT ''t_sales_store_order.FulfillmentType already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- drop IsMultiLabel
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_sales_store_order'
AND COLUMN_NAME = 'IsMultiLabel'
);
SET @sql := IF(
@exists > 0,
'ALTER TABLE `t_sales_store_order` DROP COLUMN `IsMultiLabel`',
'SELECT ''t_sales_store_order.IsMultiLabel already dropped'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- drop CustomTags
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_sales_store_order'
AND COLUMN_NAME = 'CustomTags'
);
SET @sql := IF(
@exists > 0,
'ALTER TABLE `t_sales_store_order` DROP COLUMN `CustomTags`',
'SELECT ''t_sales_store_order.CustomTags already dropped'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- drop line IsShipExcluded
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_sales_store_order_line'
AND COLUMN_NAME = 'IsShipExcluded'
);
SET @sql := IF(
@exists > 0,
'ALTER TABLE `t_sales_store_order_line` DROP COLUMN `IsShipExcluded`',
'SELECT ''t_sales_store_order_line.IsShipExcluded already dropped'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,81 @@
using Oms.Application.Dtos.Marketing;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 订单支付入参(支付时选券)。
/// </summary>
public class OrderPayInput : OrderActionInput
{
/// <summary>
/// 选用的优惠券实例 Id:null 沿用订单已绑定的券(不可用则不使用);0 不使用券;大于 0 使用指定券。
/// </summary>
public int? CouponInstanceId { get; set; }
}
/// <summary>
/// 订单支付预览入参。
/// </summary>
public class OrderPayPreviewInput
{
/// <summary>
/// 订单主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 试算用券:null 自动推荐(优先已绑定券,否则选优惠最大且优于会员折的券);0 不使用券;大于 0 使用指定券。
/// </summary>
public int? CouponInstanceId { get; set; }
}
/// <summary>
/// 订单支付预览:应付拆解与可选券。
/// </summary>
public class OrderPayPreviewOutput
{
/// <summary>
/// 订单主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 应收小计(折前)。
/// </summary>
public decimal ReceivableSubtotal { get; set; }
/// <summary>
/// 基础运费(券门槛与抵扣口径)。
/// </summary>
public decimal BaseFreightAmount { get; set; }
/// <summary>
/// 会员折扣金额(与券互斥,用券时为 0)。
/// </summary>
public decimal LevelDiscountAmount { get; set; }
/// <summary>
/// 优惠券抵扣金额。
/// </summary>
public decimal CouponDiscountAmount { get; set; }
/// <summary>
/// 实际应付金额(支付扣款金额)。
/// </summary>
public decimal PayableAmount { get; set; }
/// <summary>
/// 会员等级名称。
/// </summary>
public string? MemberLevelName { get; set; }
/// <summary>
/// 本次试算实际生效的券实例 Id;为空表示未用券。
/// </summary>
public int? AppliedCouponInstanceId { get; set; }
/// <summary>
/// 当前客户可选券(CanUse=false 时附不可用原因,含「优惠不高于会员折扣」)。
/// </summary>
public List<CouponAvailableOutput> AvailableCoupons { get; set; } = [];
}

View File

@ -180,8 +180,6 @@ public class PlatformStoreOrderLineOutput
public string? Currency { get; set; } public string? Currency { get; set; }
public bool IsShipExcluded { get; set; }
public int? MappedSkuId { get; set; } public int? MappedSkuId { get; set; }
public string? MappedSkuCode { get; set; } public string? MappedSkuCode { get; set; }
@ -245,9 +243,11 @@ public class PlatformStoreOrderOutput
public string? ShippingLabelUrl { get; set; } public string? ShippingLabelUrl { get; set; }
public bool IsMultiLabel { get; set; } /// <summary>平台物流商。</summary>
public string? ShippingProvider { get; set; }
public string? CustomTags { get; set; } /// <summary>平台履约类型。</summary>
public string? FulfillmentType { get; set; }
public bool IsSuspended { get; set; } public bool IsSuspended { get; set; }

View File

@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Http;
namespace Oms.Application.Dtos.System;
/// <summary>
/// 国家/地区 Excel 导入入参。
/// </summary>
public class CountryImportInput
{
/// <summary>
/// 导入模式:incremental=按二字码新增或更新;full=按二字码新增或更新,并软删除文件中未出现的国家。
/// </summary>
public string ImportMode { get; set; } = ImportModes.Incremental;
/// <summary>
/// Excel 文件。
/// </summary>
public IFormFile File { get; set; } = null!;
public static class ImportModes
{
public const string Incremental = "incremental";
public const string Full = "full";
}
}

View File

@ -0,0 +1,48 @@
namespace Oms.Application.Dtos.System;
/// <summary>
/// 国家/地区 Excel 导入结果。
/// </summary>
public class CountryImportOutput
{
/// <summary>
/// 成功写入条数。
/// </summary>
public int SuccessCount { get; set; }
/// <summary>
/// 跳过条数。
/// </summary>
public int SkipCount { get; set; }
/// <summary>
/// 失败条数。
/// </summary>
public int FailCount { get; set; }
/// <summary>
/// 全量导入时软删除的条数。
/// </summary>
public int DeletedCount { get; set; }
/// <summary>
/// 失败行明细。
/// </summary>
public List<CountryImportFailRow> Failures { get; set; } = [];
}
/// <summary>
/// 导入失败行。
/// </summary>
public class CountryImportFailRow
{
/// <summary>
/// Excel 行号(含表头,从 2 起为数据行)。
/// </summary>
public int RowNumber { get; set; }
/// <summary>
/// 失败原因。
/// </summary>
public string Reason { get; set; } = string.Empty;
}

View File

@ -24,4 +24,14 @@ public class CountryOutput
/// ISO 3166-1 alpha-2 国家二字码。 /// ISO 3166-1 alpha-2 国家二字码。
/// </summary> /// </summary>
public string CountryCode { get; set; } = string.Empty; public string CountryCode { get; set; } = string.Empty;
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 更新时间;未更新时为空。
/// </summary>
public DateTime? UpdateTime { get; set; }
} }

View File

@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.System;
/// <summary>
/// 新增国家/地区入参。
/// </summary>
public class CreateCountryInput
{
/// <summary>
/// 国家中文名。
/// </summary>
[Required]
public string ChineseName { get; set; } = string.Empty;
/// <summary>
/// 国家英文名。
/// </summary>
[Required]
public string EnglishName { get; set; } = string.Empty;
/// <summary>
/// ISO 3166-1 alpha-2 国家二字码。
/// </summary>
[Required]
public string CountryCode { get; set; } = string.Empty;
}

View File

@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.System;
/// <summary>
/// 更新国家/地区入参。
/// </summary>
public class UpdateCountryInput
{
/// <summary>
/// 主键。
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// 国家中文名。
/// </summary>
[Required]
public string ChineseName { get; set; } = string.Empty;
/// <summary>
/// 国家英文名。
/// </summary>
[Required]
public string EnglishName { get; set; } = string.Empty;
/// <summary>
/// ISO 3166-1 alpha-2 国家二字码。
/// </summary>
[Required]
public string CountryCode { get; set; } = string.Empty;
}

View File

@ -20,6 +20,29 @@ internal static class PlatformShopCrypto
} }
public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
/// <summary>
/// TikTok Shop 202309 签名,对齐 WMS <c>TiktokApi.makeTiktokSign</c>:
/// 查询参数按 key 排序后拼接 key+value,有请求体则原样接在后面,两侧包 appSecret,HMAC-SHA256 十六进制。
/// </summary>
public static string TiktokSign(string path, IReadOnlyDictionary<string, string> query, string? body, string appSecret)
{
var input = new StringBuilder(path);
foreach (var key in query.Keys
.Where(k => k is not ("sign" or "access_token"))
.OrderBy(k => k, StringComparer.Ordinal))
{
input.Append(key).Append(query[key]);
}
if (!string.IsNullOrEmpty(body))
{
input.Append(body);
}
var payload = appSecret + input + appSecret;
return HmacSha256Hex(payload, appSecret);
}
} }
/// <summary> /// <summary>

View File

@ -11,7 +11,7 @@ namespace Oms.Application.Services.Orders;
public partial class OrderService public partial class OrderService
{ {
internal Task PayForBatchAsync(int orderId, CancellationToken cancellationToken = default) => internal Task PayForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
PayAsync(new OrderActionInput { Id = orderId }); PayAsync(new OrderPayInput { Id = orderId });
internal Task PreDeclareForBatchAsync(int orderId, CancellationToken cancellationToken = default) => internal Task PreDeclareForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
PreDeclareAsync(new OrderActionInput { Id = orderId }); PreDeclareAsync(new OrderActionInput { Id = orderId });

View File

@ -3378,7 +3378,8 @@ public partial class OrderService
receivableSubtotal, receivableSubtotal,
baseFreightAmount, baseFreightAmount,
order.CouponInstanceId, order.CouponInstanceId,
order.ConsigneeCountry); order.ConsigneeCountry,
order.Id);
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId); marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
} }

View File

@ -0,0 +1,142 @@
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Marketing;
using Oms.Application.Dtos.Orders;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders;
public partial class OrderService
{
private const string CouponNotBetterThanLevelReason = "优惠不高于会员折扣";
/// <summary>
/// 支付预览:按当前应收计算会员折/券互斥后的应付,并返回本单可选券。
/// </summary>
/// <param name="input">入参:订单 Id;couponInstanceId 为 null 自动推荐,0 不用券,大于 0 试算指定券。</param>
/// <returns>应付拆解、实际生效券与可选券列表;指定券不可用(未解锁/过期/线路不符等)时抛业务异常。</returns>
[HttpGet("/order/pay-preview")]
public async Task<OrderPayPreviewOutput> GetPayPreviewAsync([FromQuery] OrderPayPreviewInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
var marketingHelper = LazyServiceProvider.GetRequiredService<MarketingOrderHelper>();
var memberOnly = await marketingHelper.BuildBreakdownAsync(
order.CustomerId,
receivableSubtotal,
baseFreightAmount,
null,
order.ConsigneeCountry,
order.Id);
var coupons = await marketingHelper.BuildAvailableCouponsAsync(
order.CustomerId,
baseFreightAmount,
order.ConsigneeCountry,
order.Id);
foreach (var coupon in coupons.Where(x => x.CanUse
&& (x.EstimatedDiscount ?? 0M) <= memberOnly.LevelDiscountAmount))
{
coupon.CanUse = false;
coupon.UnavailableReason = CouponNotBetterThanLevelReason;
}
coupons = coupons
.OrderByDescending(x => x.CanUse)
.ThenByDescending(x => x.EstimatedDiscount ?? 0M)
.ThenBy(x => x.ValidEndTime)
.ToList();
var targetCouponId = input.CouponInstanceId switch
{
0 => null,
> 0 => input.CouponInstanceId,
_ => ResolveRecommendedPayCouponId(order, coupons)
};
var breakdown = targetCouponId.HasValue
? await marketingHelper.BuildBreakdownAsync(
order.CustomerId,
receivableSubtotal,
baseFreightAmount,
targetCouponId,
order.ConsigneeCountry,
order.Id)
: memberOnly;
return new OrderPayPreviewOutput
{
Id = order.Id,
ReceivableSubtotal = breakdown.ReceivableSubtotal,
BaseFreightAmount = breakdown.BaseFreightAmount,
LevelDiscountAmount = breakdown.LevelDiscountAmount,
CouponDiscountAmount = breakdown.CouponDiscountAmount,
PayableAmount = breakdown.PayableAmount,
MemberLevelName = breakdown.MemberLevelName,
AppliedCouponInstanceId = breakdown.CouponInstanceId,
AvailableCoupons = coupons
};
}
/// <summary>
/// 计算支付应付:couponInstanceId 为 null 沿用订单已绑定券(不可用则不用券),0 不用券,大于 0 使用指定券。
/// </summary>
private async Task<MarketingPriceBreakdown> BuildPayBreakdownAsync(
Order order,
IEnumerable<OrderFee> fees,
int? couponInstanceId)
{
var feeList = fees as IList<OrderFee> ?? fees.ToList();
var receivableSubtotal = MarketingFeeHelper.SumReceivable(feeList);
var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(feeList);
var marketingHelper = LazyServiceProvider.GetRequiredService<MarketingOrderHelper>();
Task<MarketingPriceBreakdown> Build(int? couponId) =>
marketingHelper.BuildBreakdownAsync(
order.CustomerId,
receivableSubtotal,
baseFreightAmount,
couponId,
order.ConsigneeCountry,
order.Id);
if (couponInstanceId is > 0)
{
var breakdown = await Build(couponInstanceId);
if (breakdown.CouponInstanceId != couponInstanceId)
{
throw new BusinessException("所选优惠券未达使用门槛或优惠不高于会员折扣,请重新选择");
}
return breakdown;
}
if (couponInstanceId == 0 || order.CouponInstanceId is not > 0)
{
return await Build(null);
}
try
{
return await Build(order.CouponInstanceId);
}
catch (BusinessException)
{
return await Build(null);
}
}
private static int? ResolveRecommendedPayCouponId(Order order, List<CouponAvailableOutput> coupons)
{
if (order.CouponInstanceId is > 0
&& coupons.Any(x => x.CanUse && x.CouponInstanceId == order.CouponInstanceId.Value))
{
return order.CouponInstanceId;
}
return coupons.FirstOrDefault(x => x.CanUse)?.CouponInstanceId;
}
}

View File

@ -127,12 +127,12 @@ public partial class OrderService : ApplicationService
} }
/// <summary> /// <summary>
/// 执行订单支付。 /// 执行订单支付(余额扣款,支持支付时选券)。
/// </summary> /// </summary>
/// <param name="input">入参:订单操作参数。</param> /// <param name="input">入参:订单 Id;couponInstanceId 为 null 沿用已绑定券,0 不用券,大于 0 使用指定券。</param>
/// <returns>无返回体,仅完成订单支付处理。</returns> /// <returns>无返回体;指定券不可用、余额不足或订单状态不可支付时抛业务异常。</returns>
[HttpPost("/order/pay")] [HttpPost("/order/pay")]
public async Task PayAsync(OrderActionInput input) public async Task PayAsync(OrderPayInput input)
{ {
var order = await GetOrderOrThrowAsync(input.Id); var order = await GetOrderOrThrowAsync(input.Id);
if (order.IsPaid > 0) if (order.IsPaid > 0)
@ -152,20 +152,21 @@ public partial class OrderService : ApplicationService
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order); var beforeSnap = await CaptureOrderAuditSnapshotAsync(order);
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
var payAmount = order.PayableAmount ?? fees var breakdown = await BuildPayBreakdownAsync(order, fees, input.CouponInstanceId);
.Where(x => x.Type == OrderFeeTypeCodes.Receivable) if (breakdown.ReceivableSubtotal <= 0)
.Sum(x => x.Amount);
if (payAmount <= 0)
{ {
throw new BusinessException("订单应付金额无效,无法支付"); throw new BusinessException("订单应付金额无效,无法支付");
} }
var payAmount = breakdown.PayableAmount;
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer == null) if (customer == null)
{ {
throw new BusinessException("客户不存在"); throw new BusinessException("客户不存在");
} }
if (payAmount > 0)
{
var balanceMutator = LazyServiceProvider.GetRequiredService<CustomerBalanceMutator>(); var balanceMutator = LazyServiceProvider.GetRequiredService<CustomerBalanceMutator>();
await balanceMutator.DebitOrderPayAsync( await balanceMutator.DebitOrderPayAsync(
customer, customer,
@ -173,12 +174,20 @@ public partial class OrderService : ApplicationService
order.Id, order.Id,
order.ServiceNo, order.ServiceNo,
$"订单支付 {order.ServiceNo}"); $"订单支付 {order.ServiceNo}");
}
if (order.CouponInstanceId.HasValue)
{
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>(); var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
if (order.CouponInstanceId is > 0 && order.CouponInstanceId != breakdown.CouponInstanceId)
{
await couponManager.ReleaseLockAsync(order.CouponInstanceId.Value, order.Id);
}
LazyServiceProvider.GetRequiredService<MarketingOrderHelper>()
.ApplySnapshotToOrder(order, breakdown, null);
if (breakdown.CouponInstanceId.HasValue)
{
var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id); var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id);
await couponManager.MarkUsedAsync(order.CouponInstanceId.Value, mainCustomerId, order.Id); await couponManager.MarkUsedAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id);
} }
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer); var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
@ -197,12 +206,15 @@ public partial class OrderService : ApplicationService
order = await GetOrderOrThrowAsync(order.Id); order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order); var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
var amountText = payAmount.ToString("0.####", CultureInfo.InvariantCulture); var amountText = payAmount.ToString("0.####", CultureInfo.InvariantCulture);
var couponText = breakdown.CouponDiscountAmount > 0
? $",优惠券抵扣 {breakdown.CouponDiscountAmount.ToString("0.####", CultureInfo.InvariantCulture)}"
: string.Empty;
await OperationLogWriter.TryWriteAsync( await OperationLogWriter.TryWriteAsync(
order, order,
OrderOperationActionCodes.Pay, OrderOperationActionCodes.Pay,
beforeSnap, beforeSnap,
afterSnap, afterSnap,
$"支付金额 {amountText}"); $"支付金额 {amountText}{couponText}");
} }
/// <summary> /// <summary>

View File

@ -187,6 +187,23 @@ internal sealed class PlatformStoreOrderFulfillmentService(
try try
{ {
var detail = await fulfillmentClient.FetchOrderDetailAsync(store, order.PlatformOrderNo);
if (detail != null)
{
await UpsertPulledOrderAsync(order.CustomerId, store, detail);
var refreshed = await storeOrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order;
return new PlatformStoreOrderBatchActionItemOutput
{
Id = refreshed.Id,
PlatformOrderNo = refreshed.PlatformOrderNo,
Success = true,
Message = "同步成功",
FulfillStatus = refreshed.FulfillStatus,
ShippingLabelNo = refreshed.ShippingLabelNo,
ShippingLabelUrl = refreshed.ShippingLabelUrl
};
}
var result = await fulfillmentClient.FetchOrderStatusAsync(store, order.PlatformOrderNo); var result = await fulfillmentClient.FetchOrderStatusAsync(store, order.PlatformOrderNo);
if (!result.Success) if (!result.Success)
{ {
@ -263,6 +280,11 @@ internal sealed class PlatformStoreOrderFulfillmentService(
{ {
order.ShippingLabelNo = result.ShippingLabelNo; order.ShippingLabelNo = result.ShippingLabelNo;
} }
else if (result.LabelPending)
{
// 对齐 WMS:准备发货后先标记面单待获取
order.ShippingLabelNo = "系统获取中";
}
if (!string.IsNullOrWhiteSpace(result.ShippingLabelUrl)) if (!string.IsNullOrWhiteSpace(result.ShippingLabelUrl))
{ {
@ -271,6 +293,36 @@ internal sealed class PlatformStoreOrderFulfillmentService(
order.LastSyncTime = DateTime.Now; order.LastSyncTime = DateTime.Now;
await storeOrderRepository.UpdateAsync(order); await storeOrderRepository.UpdateAsync(order);
// TikTok:发货后再拉详情,回写运单号等;面单未就绪时保留「系统获取中」
if (string.Equals(order.PlatformCode, "tiktok", StringComparison.OrdinalIgnoreCase))
{
try
{
var detail = await fulfillmentClient.FetchOrderDetailAsync(store, order.PlatformOrderNo);
if (detail != null)
{
if (result.LabelPending && string.IsNullOrWhiteSpace(detail.ShippingLabelUrl))
{
detail.ShippingLabelUrl = "系统获取中";
detail.ShippingLabelNo ??= "系统获取中";
}
detail.PlatformStatus ??= "AWAITING_COLLECTION";
detail.PlatformStatusLabel ??= detail.PlatformStatus;
detail.FulfillStatus = string.IsNullOrWhiteSpace(detail.FulfillStatus)
? "toShip"
: detail.FulfillStatus;
await UpsertPulledOrderAsync(order.CustomerId, store, detail);
order = await storeOrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order;
}
}
catch
{
// 详情回写失败不影响 RTS 成功结果
}
}
return new PlatformStoreOrderBatchActionItemOutput return new PlatformStoreOrderBatchActionItemOutput
{ {
Id = order.Id, Id = order.Id,
@ -307,6 +359,15 @@ internal sealed class PlatformStoreOrderFulfillmentService(
} }
entity.OrderCountry = pulled.OrderCountry ?? entity.OrderCountry; entity.OrderCountry = pulled.OrderCountry ?? entity.OrderCountry;
if (!string.IsNullOrWhiteSpace(pulled.RecipientName)) entity.RecipientName = pulled.RecipientName;
if (!string.IsNullOrWhiteSpace(pulled.RecipientPhone)) entity.RecipientPhone = pulled.RecipientPhone;
if (!string.IsNullOrWhiteSpace(pulled.RecipientEmail)) entity.RecipientEmail = pulled.RecipientEmail;
if (!string.IsNullOrWhiteSpace(pulled.RecipientCountry)) entity.RecipientCountry = pulled.RecipientCountry;
if (!string.IsNullOrWhiteSpace(pulled.RecipientProvince)) entity.RecipientProvince = pulled.RecipientProvince;
if (!string.IsNullOrWhiteSpace(pulled.RecipientCity)) entity.RecipientCity = pulled.RecipientCity;
if (!string.IsNullOrWhiteSpace(pulled.RecipientZipCode)) entity.RecipientZipCode = pulled.RecipientZipCode;
if (!string.IsNullOrWhiteSpace(pulled.RecipientAddress1)) entity.RecipientAddress1 = pulled.RecipientAddress1;
if (!string.IsNullOrWhiteSpace(pulled.RecipientAddress2)) entity.RecipientAddress2 = pulled.RecipientAddress2;
entity.ItemCount = pulled.ItemCount > 0 ? pulled.ItemCount : entity.ItemCount; entity.ItemCount = pulled.ItemCount > 0 ? pulled.ItemCount : entity.ItemCount;
entity.TotalAmount = pulled.TotalAmount ?? entity.TotalAmount; entity.TotalAmount = pulled.TotalAmount ?? entity.TotalAmount;
entity.Currency = pulled.Currency ?? entity.Currency; entity.Currency = pulled.Currency ?? entity.Currency;
@ -316,6 +377,8 @@ internal sealed class PlatformStoreOrderFulfillmentService(
entity.FulfillStatus = pulled.FulfillStatus; entity.FulfillStatus = pulled.FulfillStatus;
entity.ShippingLabelNo = pulled.ShippingLabelNo ?? entity.ShippingLabelNo; entity.ShippingLabelNo = pulled.ShippingLabelNo ?? entity.ShippingLabelNo;
entity.ShippingLabelUrl = pulled.ShippingLabelUrl ?? entity.ShippingLabelUrl; entity.ShippingLabelUrl = pulled.ShippingLabelUrl ?? entity.ShippingLabelUrl;
if (!string.IsNullOrWhiteSpace(pulled.ShippingProvider)) entity.ShippingProvider = pulled.ShippingProvider;
if (!string.IsNullOrWhiteSpace(pulled.FulfillmentType)) entity.FulfillmentType = pulled.FulfillmentType;
entity.PlatformExtraJson = pulled.ExtraJson ?? entity.PlatformExtraJson; entity.PlatformExtraJson = pulled.ExtraJson ?? entity.PlatformExtraJson;
entity.LastSyncTime = now; entity.LastSyncTime = now;
PlatformStoreOrderParcelBuilder.TryFillRecipientFields(entity, pulled.ExtraJson); PlatformStoreOrderParcelBuilder.TryFillRecipientFields(entity, pulled.ExtraJson);
@ -348,7 +411,9 @@ internal sealed class PlatformStoreOrderFulfillmentService(
PlatformLineId = line.PlatformLineId, PlatformLineId = line.PlatformLineId,
PlatformSku = line.PlatformSku, PlatformSku = line.PlatformSku,
ProductName = line.ProductName, ProductName = line.ProductName,
Quantity = line.Quantity, ProductImageUrl = line.ProductImageUrl,
VariantText = line.VariantText,
Quantity = line.Quantity > 0 ? line.Quantity : 1,
UnitPrice = line.UnitPrice, UnitPrice = line.UnitPrice,
Currency = line.Currency Currency = line.Currency
}); });

View File

@ -107,7 +107,7 @@ public static class PlatformStoreOrderParcelBuilder
IReadOnlyCollection<SalesStoreOrderLine> lines) IReadOnlyCollection<SalesStoreOrderLine> lines)
{ {
var items = new List<OrderParcelItemInput>(); var items = new List<OrderParcelItemInput>();
foreach (var line in lines.Where(x => !x.IsShipExcluded)) foreach (var line in lines)
{ {
var cnName = string.IsNullOrWhiteSpace(line.ProductName) var cnName = string.IsNullOrWhiteSpace(line.ProductName)
? (string.IsNullOrWhiteSpace(line.PlatformSku) ? "Platform Item" : line.PlatformSku.Trim()) ? (string.IsNullOrWhiteSpace(line.PlatformSku) ? "Platform Item" : line.PlatformSku.Trim())
@ -157,7 +157,7 @@ public static class PlatformStoreOrderParcelBuilder
IReadOnlyDictionary<int, CSku> skuMap) IReadOnlyDictionary<int, CSku> skuMap)
{ {
var items = new List<OrderParcelItemInput>(); var items = new List<OrderParcelItemInput>();
foreach (var line in lines.Where(x => !x.IsShipExcluded)) foreach (var line in lines)
{ {
if (!line.MappedSkuId.HasValue || !skuMap.TryGetValue(line.MappedSkuId.Value, out var sku)) if (!line.MappedSkuId.HasValue || !skuMap.TryGetValue(line.MappedSkuId.Value, out var sku))
{ {
@ -201,7 +201,7 @@ public static class PlatformStoreOrderParcelBuilder
decimal maxHeight = DefaultHeightCm; decimal maxHeight = DefaultHeightCm;
decimal totalWeightKg = 0m; decimal totalWeightKg = 0m;
foreach (var line in lines.Where(x => !x.IsShipExcluded && x.MappedSkuId.HasValue)) foreach (var line in lines.Where(x => x.MappedSkuId.HasValue))
{ {
if (!skuMap.TryGetValue(line.MappedSkuId!.Value, out var sku)) if (!skuMap.TryGetValue(line.MappedSkuId!.Value, out var sku))
{ {
@ -306,7 +306,10 @@ public static class PlatformStoreOrderParcelBuilder
private static OrderParcelReceiverInput? ParseReceiverNode(JsonNode node) private static OrderParcelReceiverInput? ParseReceiverNode(JsonNode node)
{ {
var candidates = new List<JsonNode?> { node }; var candidates = new List<JsonNode?> { node };
foreach (var key in new[] { "recipient", "receiver", "shippingAddress", "address" }) foreach (var key in new[]
{
"recipient_address", "recipientAddress", "recipient", "receiver", "shippingAddress", "address"
})
{ {
if (node[key] != null) if (node[key] != null)
{ {
@ -321,10 +324,22 @@ public static class PlatformStoreOrderParcelBuilder
continue; continue;
} }
var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName"); var (province, city, countryFromDistrict) = ParseTiktokDistrictInfo(candidate);
var country = ReadText(candidate, "countryCode", "country", "region", "orderCountry") ?? ReadText(node, "orderCountry"); var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName", "first_name");
var zip = ReadText(candidate, "zipCode", "zip", "postalCode", "postcode"); var country = ReadText(candidate, "countryCode", "country", "region_code", "region", "orderCountry")
var address = ReadText(candidate, "streetAddress1", "address", "address1", "fullAddress", "detailAddress"); ?? countryFromDistrict
?? ReadText(node, "orderCountry");
var zip = ReadText(candidate, "zipCode", "zip", "postal_code", "postalCode", "postcode");
var address = ReadText(
candidate,
"streetAddress1",
"address_line1",
"address_detail",
"address",
"address1",
"full_address",
"fullAddress",
"detailAddress");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(country) || string.IsNullOrWhiteSpace(zip) || string.IsNullOrWhiteSpace(address)) if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(country) || string.IsNullOrWhiteSpace(zip) || string.IsNullOrWhiteSpace(address))
{ {
continue; continue;
@ -334,14 +349,14 @@ public static class PlatformStoreOrderParcelBuilder
{ {
RecipientsName = name, RecipientsName = name,
CompanyName = ReadText(candidate, "companyName", "company"), CompanyName = ReadText(candidate, "companyName", "company"),
Phone = ReadText(candidate, "phone", "mobile", "phoneNumber"), Phone = ReadText(candidate, "phone", "mobile", "phone_number", "phoneNumber"),
TelePhone = ReadText(candidate, "telePhone", "tel"), TelePhone = ReadText(candidate, "telePhone", "tel"),
Email = ReadText(candidate, "email"), Email = ReadText(candidate, "email") ?? ReadText(node, "buyer_email"),
CountryCode = country, CountryCode = country,
Province = ReadText(candidate, "province", "state", "regionState"), Province = ReadText(candidate, "province", "state", "regionState") ?? province,
City = ReadText(candidate, "city"), City = ReadText(candidate, "city") ?? city,
StreetAddress1 = address, StreetAddress1 = address,
StreetAddress2 = ReadText(candidate, "streetAddress2", "address2"), StreetAddress2 = ReadText(candidate, "streetAddress2", "address_line2", "address2"),
ZipCode = zip ZipCode = zip
}; };
} }
@ -349,6 +364,46 @@ public static class PlatformStoreOrderParcelBuilder
return null; return null;
} }
/// <summary>
/// 解析 TikTok recipient_address.district_info:L0 国家、L1 州省、L3 城市。
/// </summary>
private static (string? Province, string? City, string? CountryCode) ParseTiktokDistrictInfo(JsonNode candidate)
{
var districts = candidate["district_info"]?.AsArray();
if (districts == null || districts.Count == 0)
{
return (null, null, null);
}
string? province = null;
string? city = null;
string? country = null;
foreach (var district in districts)
{
if (district == null) continue;
var level = district["address_level"]?.ToString();
var name = district["address_name"]?.ToString()?.Trim();
var iso = district["iso_code"]?.ToString()?.Trim();
switch (level)
{
case "L0":
country = !string.IsNullOrWhiteSpace(iso) ? iso : name;
break;
case "L1":
province = name;
break;
case "L3":
city = name;
break;
case "L2" when string.IsNullOrWhiteSpace(city):
city = name;
break;
}
}
return (province, city, country);
}
private static string? ReadText(JsonNode node, params string[] keys) private static string? ReadText(JsonNode node, params string[] keys)
{ {
foreach (var key in keys) foreach (var key in keys)

View File

@ -553,7 +553,7 @@ public class PlatformStoreOrderService : ApplicationService
var order = await GetOrderOrThrowAsync(customerId, storeOrderId); var order = await GetOrderOrThrowAsync(customerId, storeOrderId);
await EnsureCanCreateLogisticsAsync(order); await EnsureCanCreateLogisticsAsync(order);
var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id && !x.IsShipExcluded); var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id);
if (lines.Count == 0) if (lines.Count == 0)
{ {
throw new BusinessException("没有可发货的商品行"); throw new BusinessException("没有可发货的商品行");
@ -710,7 +710,7 @@ public class PlatformStoreOrderService : ApplicationService
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == resolvedWarehouseId.Value) var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == resolvedWarehouseId.Value)
?? throw new BusinessException("仓库不存在"); ?? throw new BusinessException("仓库不存在");
var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id && !x.IsShipExcluded); var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id);
if (lines.Count == 0) if (lines.Count == 0)
{ {
throw new BusinessException("没有可发货的商品行"); throw new BusinessException("没有可发货的商品行");
@ -1103,8 +1103,8 @@ public class PlatformStoreOrderService : ApplicationService
PlatformStatusLabel = order.PlatformStatusLabel, PlatformStatusLabel = order.PlatformStatusLabel,
ShippingLabelNo = order.ShippingLabelNo, ShippingLabelNo = order.ShippingLabelNo,
ShippingLabelUrl = order.ShippingLabelUrl, ShippingLabelUrl = order.ShippingLabelUrl,
IsMultiLabel = order.IsMultiLabel, ShippingProvider = order.ShippingProvider,
CustomTags = order.CustomTags, FulfillmentType = order.FulfillmentType,
IsSuspended = order.IsSuspended, IsSuspended = order.IsSuspended,
WarehouseId = order.WarehouseId, WarehouseId = order.WarehouseId,
LinkedOrderId = order.LinkedOrderId, LinkedOrderId = order.LinkedOrderId,
@ -1129,7 +1129,6 @@ public class PlatformStoreOrderService : ApplicationService
UnitPrice = line.UnitPrice, UnitPrice = line.UnitPrice,
Quantity = line.Quantity, Quantity = line.Quantity,
Currency = line.Currency, Currency = line.Currency,
IsShipExcluded = line.IsShipExcluded,
MappedSkuId = line.MappedSkuId, MappedSkuId = line.MappedSkuId,
MappedSkuCode = mappedSkuCode MappedSkuCode = mappedSkuCode
}; };

View File

@ -1,6 +1,9 @@
using Fake.Application; using Fake.Application;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.System; using Oms.Application.Dtos.System;
using Oms.Application.Shared;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories; using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.System; namespace Oms.Application.Services.System;
@ -16,20 +19,249 @@ public class CountryService : ApplicationService
/// <summary> /// <summary>
/// 查询国家列表。 /// 查询国家列表。
/// </summary> /// </summary>
/// <returns>国家列表,返回全部有效国家主数据。</returns> /// <returns>国家列表,返回全部有效国家主数据,按二字码、英文名排序。</returns>
[HttpGet("/system/country/list")] [HttpGet("/system/country/list")]
public async Task<List<CountryOutput>> GetListAsync() public async Task<List<CountryOutput>> GetListAsync()
{ {
return (await CountryRepository.GetListAsync()) return (await CountryRepository.GetListAsync())
.OrderBy(x => x.CountryCode) .OrderBy(x => x.CountryCode)
.ThenBy(x => x.EnglishName) .ThenBy(x => x.EnglishName)
.Select(x => new CountryOutput .Select(ToOutput)
.ToList();
}
/// <summary>
/// 新增国家/地区。二字码已存在时失败;若仅存在同码软删除记录则恢复该记录。
/// </summary>
/// <param name="input">中文名、英文名、二字码。</param>
/// <returns>无返回体,仅完成新增。</returns>
[HttpPost("/system/country/create")]
public async Task CreateAsync(CreateCountryInput input)
{
var row = ToImportRow(input.ChineseName, input.EnglishName, input.CountryCode);
await SaveAsync(row, excludeId: null, updateWhenCodeExists: false);
}
/// <summary>
/// 更新国家/地区。二字码与其他有效记录冲突时失败。
/// </summary>
/// <param name="input">主键及中文名、英文名、二字码。</param>
/// <returns>无返回体,仅完成更新。</returns>
[HttpPost("/system/country/update")]
public async Task UpdateAsync(UpdateCountryInput input)
{
var entity = await GetOrThrowAsync(input.Id);
var row = ToImportRow(input.ChineseName, input.EnglishName, input.CountryCode);
await SaveAsync(row, excludeId: entity.Id, updateWhenCodeExists: false);
}
/// <summary>
/// 删除国家/地区(软删除)。
/// </summary>
/// <param name="input">目标主键。</param>
/// <returns>无返回体,仅完成删除。</returns>
[HttpPost("/system/country/delete")]
public async Task DeleteAsync(IdInput input)
{
var entity = await GetOrThrowAsync(input.Id);
await CountryRepository.DeleteAsync(entity);
}
/// <summary>
/// 下载国家/地区导入模板。
/// </summary>
/// <returns>xlsx 文件,列为国家二字码、中文名、英文名。</returns>
[HttpGet("/system/country/import-template")]
public async Task<FileContentResult> DownloadImportTemplateAsync()
{
var bytes = await CountryImportHelper.BuildTemplateBytesAsync();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = CountryImportHelper.TemplateFileName
};
}
/// <summary>
/// 导入国家/地区(Excel)。增量按二字码新增或更新;全量还会软删除文件中未出现的国家。
/// </summary>
/// <param name="input">Excel 文件与导入模式。</param>
/// <returns>成功、跳过、失败及全量删除条数。</returns>
[HttpPost("/system/country/import")]
public async Task<CountryImportOutput> ImportAsync([FromForm] CountryImportInput input)
{
if (input.File == null || input.File.Length == 0)
{
throw new BusinessException("请上传 Excel 文件");
}
var importMode = NormalizeImportMode(input.ImportMode);
await using var stream = input.File.OpenReadStream();
var parsedRows = await CountryImportHelper.ParseImportRowsAsync(stream);
if (parsedRows.Count == 0)
{
throw new BusinessException("Excel 中无有效数据行");
}
var output = new CountryImportOutput();
var touchedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (rowNumber, row) in parsedRows)
{
var rawCode = row.CountryCode?.Trim();
if (!string.IsNullOrWhiteSpace(rawCode))
{
touchedCodes.Add(rawCode.ToUpperInvariant());
}
try
{
CountryImportHelper.Normalize(row);
var code = row.CountryCode ?? string.Empty;
if (!seenCodes.Add(code))
{
output.SkipCount++;
output.Failures.Add(new CountryImportFailRow
{
RowNumber = rowNumber,
Reason = "文件内二字码重复,已跳过"
});
continue;
}
await SaveAsync(row, excludeId: null, updateWhenCodeExists: true);
output.SuccessCount++;
}
catch (Exception ex)
{
output.FailCount++;
output.Failures.Add(new CountryImportFailRow
{
RowNumber = rowNumber,
Reason = ex.Message
});
}
}
if (importMode == CountryImportInput.ImportModes.Full && output.SuccessCount > 0)
{
var existing = await CountryRepository.GetListAsync();
foreach (var entity in existing)
{
var code = entity.CountryCode?.Trim().ToUpperInvariant() ?? string.Empty;
if (touchedCodes.Contains(code))
{
continue;
}
await CountryRepository.DeleteAsync(entity);
output.DeletedCount++;
}
}
return output;
}
private async Task SaveAsync(CountryImportRow row, int? excludeId, bool updateWhenCodeExists)
{
try
{
CountryImportHelper.Normalize(row);
}
catch (InvalidOperationException ex)
{
throw new BusinessException(ex.Message);
}
var active = await CountryRepository.GetByCountryCodeAsync(row.CountryCode);
if (active != null && active.Id != excludeId)
{
if (updateWhenCodeExists)
{
ApplyNames(active, row);
await CountryRepository.UpdateAsync(active);
return;
}
throw new BusinessException("国家二字码已存在");
}
var deleted = await CountryRepository.GetByCountryCodeIncludingDeletedAsync(row.CountryCode);
if (deleted != null && deleted.IsDeleted && deleted.Id != excludeId)
{
if (excludeId != null)
{
throw new BusinessException("国家二字码已被删除记录占用,请新增同码以恢复");
}
deleted.IsDeleted = false;
deleted.CountryCode = row.CountryCode;
ApplyNames(deleted, row);
await CountryRepository.UpdateAsync(deleted);
return;
}
if (excludeId != null)
{
var entity = await GetOrThrowAsync(excludeId.Value);
entity.CountryCode = row.CountryCode;
ApplyNames(entity, row);
await CountryRepository.UpdateAsync(entity);
return;
}
await CountryRepository.InsertAsync(new Country
{
CountryCode = row.CountryCode,
ChineseName = row.ChineseName,
EnglishName = row.EnglishName
});
}
private static void ApplyNames(Country entity, CountryImportRow row)
{
entity.ChineseName = row.ChineseName;
entity.EnglishName = row.EnglishName;
}
private static CountryImportRow ToImportRow(string chineseName, string englishName, string countryCode) =>
new()
{
ChineseName = chineseName,
EnglishName = englishName,
CountryCode = countryCode
};
private async Task<Country> GetOrThrowAsync(int id)
{
var entity = await CountryRepository.FirstOrDefaultAsync(x => x.Id == id);
if (entity == null)
{
throw new BusinessException("国家不存在");
}
return entity;
}
private static CountryOutput ToOutput(Country x) => new()
{ {
Id = x.Id, Id = x.Id,
ChineseName = x.ChineseName, ChineseName = x.ChineseName,
EnglishName = x.EnglishName, EnglishName = x.EnglishName,
CountryCode = x.CountryCode CountryCode = x.CountryCode,
}) CreateTime = x.CreateTime,
.ToList(); UpdateTime = x.UpdateTime == default ? null : x.UpdateTime
};
private static string NormalizeImportMode(string? mode)
{
if (string.Equals(mode, CountryImportInput.ImportModes.Full, StringComparison.OrdinalIgnoreCase)
|| string.Equals(mode, "全量", StringComparison.OrdinalIgnoreCase)
|| string.Equals(mode, "replace", StringComparison.OrdinalIgnoreCase))
{
return CountryImportInput.ImportModes.Full;
}
return CountryImportInput.ImportModes.Incremental;
} }
} }

View File

@ -0,0 +1,179 @@
using System.Text.RegularExpressions;
using MiniExcelLibs;
namespace Oms.Application.Shared;
/// <summary>
/// 国家/地区 Excel 模板与导入解析。
/// </summary>
public static class CountryImportHelper
{
public const string TemplateSheetName = "导入模板";
public const string TemplateFileName = "国家地区导入模板.xlsx";
private static readonly Regex CountryCodePattern = new("^[A-Za-z]{2}$", RegexOptions.Compiled);
private static readonly Dictionary<string, string> HeaderAliases = new(StringComparer.OrdinalIgnoreCase)
{
["国家二字码"] = nameof(CountryImportRow.CountryCode),
["二字码"] = nameof(CountryImportRow.CountryCode),
["国家代码"] = nameof(CountryImportRow.CountryCode),
["CountryCode"] = nameof(CountryImportRow.CountryCode),
["countryCode"] = nameof(CountryImportRow.CountryCode),
["中文名"] = nameof(CountryImportRow.ChineseName),
["国家中文名"] = nameof(CountryImportRow.ChineseName),
["ChineseName"] = nameof(CountryImportRow.ChineseName),
["chineseName"] = nameof(CountryImportRow.ChineseName),
["英文名"] = nameof(CountryImportRow.EnglishName),
["国家英文名"] = nameof(CountryImportRow.EnglishName),
["EnglishName"] = nameof(CountryImportRow.EnglishName),
["englishName"] = nameof(CountryImportRow.EnglishName),
};
public static async Task<byte[]> BuildTemplateBytesAsync()
{
var templateRows = new List<Dictionary<string, object?>>
{
new()
{
["国家二字码"] = "CN",
["中文名"] = "中国",
["英文名"] = "China"
},
new()
{
["国家二字码"] = "US",
["中文名"] = "美国",
["英文名"] = "United States"
}
};
var sheets = new Dictionary<string, object>
{
[TemplateSheetName] = templateRows
};
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, sheets);
return stream.ToArray();
}
public static async Task<List<(int RowNumber, CountryImportRow Row)>> ParseImportRowsAsync(Stream fileStream)
{
if (fileStream.CanSeek)
{
fileStream.Position = 0;
}
var sheetNames = MiniExcel.GetSheetNames(fileStream);
var sheetName = sheetNames.FirstOrDefault(x =>
string.Equals(x, TemplateSheetName, StringComparison.OrdinalIgnoreCase))
?? sheetNames.FirstOrDefault();
if (fileStream.CanSeek)
{
fileStream.Position = 0;
}
var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true, sheetName: sheetName);
var result = new List<(int RowNumber, CountryImportRow Row)>();
var rowNumber = 1;
foreach (var row in rows)
{
rowNumber++;
if (row is not IDictionary<string, object> rowDict || IsEmptyRow(rowDict))
{
continue;
}
result.Add((rowNumber, ParseRow(rowDict)));
}
return result;
}
public static void Normalize(CountryImportRow row)
{
row.CountryCode = row.CountryCode?.Trim().ToUpperInvariant() ?? string.Empty;
row.ChineseName = row.ChineseName?.Trim() ?? string.Empty;
row.EnglishName = row.EnglishName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(row.CountryCode))
{
throw new InvalidOperationException("国家二字码不能为空");
}
if (!CountryCodePattern.IsMatch(row.CountryCode))
{
throw new InvalidOperationException("国家二字码须为 2 位字母,如 CN、US");
}
if (string.IsNullOrWhiteSpace(row.ChineseName))
{
throw new InvalidOperationException("中文名不能为空");
}
if (row.ChineseName.Length > 128)
{
throw new InvalidOperationException("中文名不可超过 128 个字符");
}
if (string.IsNullOrWhiteSpace(row.EnglishName))
{
throw new InvalidOperationException("英文名不能为空");
}
if (row.EnglishName.Length > 128)
{
throw new InvalidOperationException("英文名不可超过 128 个字符");
}
}
private static CountryImportRow ParseRow(IDictionary<string, object> rowDict)
{
var mapped = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var (header, value) in rowDict)
{
if (string.IsNullOrWhiteSpace(header) || !HeaderAliases.TryGetValue(header.Trim(), out var field))
{
continue;
}
mapped[field] = NormalizeCell(value);
}
return new CountryImportRow
{
CountryCode = mapped.GetValueOrDefault(nameof(CountryImportRow.CountryCode)) ?? string.Empty,
ChineseName = mapped.GetValueOrDefault(nameof(CountryImportRow.ChineseName)) ?? string.Empty,
EnglishName = mapped.GetValueOrDefault(nameof(CountryImportRow.EnglishName)) ?? string.Empty
};
}
private static string? NormalizeCell(object? value)
{
if (value is null)
{
return null;
}
var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)?.Trim();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
private static bool IsEmptyRow(IDictionary<string, object> rowDict) =>
rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value)));
}
/// <summary>
/// 国家导入行。
/// </summary>
public class CountryImportRow
{
public string CountryCode { get; set; } = string.Empty;
public string ChineseName { get; set; } = string.Empty;
public string EnglishName { get; set; } = string.Empty;
}

View File

@ -314,7 +314,10 @@ public class MarketingCouponManager : IScopedDependency
await _instanceRepository.UpdateAsync(instance, cancellationToken); await _instanceRepository.UpdateAsync(instance, cancellationToken);
} }
public async Task ReleaseLockAsync(int couponInstanceId, CancellationToken cancellationToken = default) /// <summary>
/// 释放券锁定;传 <paramref name="ownerOrderId"/> 时仅释放被该订单锁定的券。
/// </summary>
public async Task ReleaseLockAsync(int couponInstanceId, int? ownerOrderId = null, CancellationToken cancellationToken = default)
{ {
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken); var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken);
if (instance == null || instance.Status != CouponInstanceStatus.Locked) if (instance == null || instance.Status != CouponInstanceStatus.Locked)
@ -322,6 +325,11 @@ public class MarketingCouponManager : IScopedDependency
return; return;
} }
if (ownerOrderId.HasValue && instance.OrderId != ownerOrderId.Value)
{
return;
}
instance.Status = CouponInstanceStatus.Available; instance.Status = CouponInstanceStatus.Available;
instance.OrderId = null; instance.OrderId = null;
instance.LockExpireTime = null; instance.LockExpireTime = null;
@ -383,10 +391,14 @@ public class MarketingCouponManager : IScopedDependency
} }
} }
/// <summary>
/// 解析券抵扣参数;<paramref name="ownerOrderId"/> 为当前订单时,被该订单锁定的券视为可用。
/// </summary>
public async Task<CouponDiscountInput?> ResolveCouponDiscountInputAsync( public async Task<CouponDiscountInput?> ResolveCouponDiscountInputAsync(
int? couponInstanceId, int? couponInstanceId,
int mainCustomerId, int mainCustomerId,
string? destinationCountry = null, string? destinationCountry = null,
int? ownerOrderId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!couponInstanceId.HasValue) if (!couponInstanceId.HasValue)
@ -395,7 +407,7 @@ public class MarketingCouponManager : IScopedDependency
} }
var instance = await GetOwnedInstanceAsync(couponInstanceId.Value, mainCustomerId, cancellationToken); var instance = await GetOwnedInstanceAsync(couponInstanceId.Value, mainCustomerId, cancellationToken);
EnsureUsable(instance); EnsureUsable(instance, ownerOrderId);
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken) var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken)
?? throw new BusinessException("券模板不存在"); ?? throw new BusinessException("券模板不存在");
if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry)) if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry))
@ -422,13 +434,31 @@ public class MarketingCouponManager : IScopedDependency
return await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(_customerRepository, customer, cancellationToken); return await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(_customerRepository, customer, cancellationToken);
} }
/// <summary>
/// 构建可选券列表;传 <paramref name="ownerOrderId"/> 时额外包含被该订单锁定且仍在有效期内的券。
/// </summary>
public async Task<List<CouponAvailableOutput>> BuildAvailableCouponsAsync( public async Task<List<CouponAvailableOutput>> BuildAvailableCouponsAsync(
int mainCustomerId, int mainCustomerId,
decimal? baseFreightAmount, decimal? baseFreightAmount,
string? destinationCountry = null, string? destinationCountry = null,
int? ownerOrderId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken); var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken);
if (ownerOrderId is > 0)
{
var now = DateTime.Now;
var orderId = ownerOrderId.Value;
var ownLocked = await _instanceRepository.GetListAsync(
x => x.CustomerId == mainCustomerId
&& x.Status == CouponInstanceStatus.Locked
&& x.OrderId == orderId
&& x.ValidStartTime <= now
&& x.ValidEndTime >= now);
var knownIds = instances.Select(x => x.Id).ToHashSet();
instances.AddRange(ownLocked.Where(x => !knownIds.Contains(x.Id)));
}
if (instances.Count == 0) if (instances.Count == 0)
{ {
return []; return [];
@ -541,7 +571,7 @@ public class MarketingCouponManager : IScopedDependency
return instance; return instance;
} }
private static void EnsureUsable(CouponInstance instance) private static void EnsureUsable(CouponInstance instance, int? ownerOrderId = null)
{ {
var now = DateTime.Now; var now = DateTime.Now;
if (instance.Status == CouponInstanceStatus.PendingUnlock) if (instance.Status == CouponInstanceStatus.PendingUnlock)
@ -566,7 +596,8 @@ public class MarketingCouponManager : IScopedDependency
if (instance.Status == CouponInstanceStatus.Locked if (instance.Status == CouponInstanceStatus.Locked
&& instance.LockExpireTime.HasValue && instance.LockExpireTime.HasValue
&& instance.LockExpireTime.Value >= now) && instance.LockExpireTime.Value >= now
&& !(ownerOrderId.HasValue && instance.OrderId == ownerOrderId.Value))
{ {
throw new BusinessException("优惠券已被其他订单占用"); throw new BusinessException("优惠券已被其他订单占用");
} }

View File

@ -53,15 +53,19 @@ public class MarketingOrderHelper : IScopedDependency
baseFreightAmount, baseFreightAmount,
couponInstanceId, couponInstanceId,
null, null,
cancellationToken); cancellationToken: cancellationToken);
} }
/// <summary>
/// 计算会员折/券互斥后的应付拆解;<paramref name="ownerOrderId"/> 为当前订单时允许使用被该订单锁定的券。
/// </summary>
public async Task<MarketingPriceBreakdown> BuildBreakdownAsync( public async Task<MarketingPriceBreakdown> BuildBreakdownAsync(
int? customerId, int? customerId,
decimal receivableSubtotal, decimal receivableSubtotal,
decimal baseFreightAmount, decimal baseFreightAmount,
int? couponInstanceId, int? couponInstanceId,
string? destinationCountry, string? destinationCountry,
int? ownerOrderId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!customerId.HasValue || customerId.Value <= 0) if (!customerId.HasValue || customerId.Value <= 0)
@ -87,7 +91,7 @@ public class MarketingOrderHelper : IScopedDependency
var priceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey); var priceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey);
var couponInput = await _couponManager.ResolveCouponDiscountInputAsync( var couponInput = await _couponManager.ResolveCouponDiscountInputAsync(
couponInstanceId, mainCustomerId, destinationCountry, cancellationToken); couponInstanceId, mainCustomerId, destinationCountry, ownerOrderId, cancellationToken);
return MarketingBillingCalculator.Calculate( return MarketingBillingCalculator.Calculate(
receivableSubtotal, receivableSubtotal,
@ -114,6 +118,7 @@ public class MarketingOrderHelper : IScopedDependency
int? customerId, int? customerId,
decimal? baseFreightAmount, decimal? baseFreightAmount,
string? destinationCountry = null, string? destinationCountry = null,
int? ownerOrderId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!customerId.HasValue || customerId.Value <= 0) if (!customerId.HasValue || customerId.Value <= 0)
@ -132,6 +137,6 @@ public class MarketingOrderHelper : IScopedDependency
sessionCustomer, sessionCustomer,
cancellationToken); cancellationToken);
return await _couponManager.BuildAvailableCouponsAsync( return await _couponManager.BuildAvailableCouponsAsync(
mainCustomer.Id, baseFreightAmount, destinationCountry, cancellationToken); mainCustomer.Id, baseFreightAmount, destinationCountry, ownerOrderId, cancellationToken);
} }
} }

View File

@ -56,9 +56,11 @@ public class SalesStoreOrder : OmsAuditedAggregateRoot
public string? ShippingLabelUrl { get; set; } public string? ShippingLabelUrl { get; set; }
public bool IsMultiLabel { get; set; } /// <summary>平台物流商,如 TikTok shipping_provider。</summary>
public string? ShippingProvider { get; set; }
public string? CustomTags { get; set; } /// <summary>平台履约类型,如 TikTok fulfillment_type。</summary>
public string? FulfillmentType { get; set; }
public bool IsSuspended { get; set; } public bool IsSuspended { get; set; }

View File

@ -24,7 +24,5 @@ public class SalesStoreOrderLine : OmsAuditedAggregateRoot
public string? Currency { get; set; } public string? Currency { get; set; }
public bool IsShipExcluded { get; set; }
public int? MappedSkuId { get; set; } public int? MappedSkuId { get; set; }
} }

View File

@ -6,4 +6,9 @@ namespace Oms.Domain.SystemAggregate.Repositories;
public interface ICountryRepository : IRepository<Country> public interface ICountryRepository : IRepository<Country>
{ {
Task<Country?> GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default); Task<Country?> GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default);
/// <summary>
/// 按二字码查询,包含已软删除记录,用于恢复同码数据。
/// </summary>
Task<Country?> GetByCountryCodeIncludingDeletedAsync(string countryCode, CancellationToken cancellationToken = default);
} }

View File

@ -11,4 +11,15 @@ public class CountryRepository : OmsSqlSugarRepository<Country>, ICountryReposit
{ {
return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken); return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken);
} }
public async Task<Country?> GetByCountryCodeIncludingDeletedAsync(
string countryCode,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
return await SoftDeleteQuery.FirstIncludingDeletedAsync<Country>(
db,
x => x.CountryCode == countryCode,
cancellationToken);
}
} }