Merge remote-tracking branch 'prod/test' into test
All checks were successful
Build and Deploy / deploy (push) Successful in 1m5s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m5s
This commit is contained in:
commit
ce75fca721
@ -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` 等共享分支会有其他人提交。**推送前必须先拉取远端,再上传**,避免覆盖或漏掉别人的提交。
|
||||||
|
|||||||
@ -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`)
|
||||||
|
- 尽量可重复执行(先判断列/表/字典是否已存在)
|
||||||
|
- **后续新增脚本一律带日期后缀**;历史未带日期的脚本不强制改名
|
||||||
|
|||||||
@ -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;
|
||||||
81
src/Oms.Application/Dtos/Orders/OrderPayDtos.cs
Normal file
81
src/Oms.Application/Dtos/Orders/OrderPayDtos.cs
Normal 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; } = [];
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
|
||||||
|
|||||||
25
src/Oms.Application/Dtos/System/CountryImportInput.cs
Normal file
25
src/Oms.Application/Dtos/System/CountryImportInput.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/Oms.Application/Dtos/System/CountryImportOutput.cs
Normal file
48
src/Oms.Application/Dtos/System/CountryImportOutput.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/Oms.Application/Dtos/System/CreateCountryInput.cs
Normal file
27
src/Oms.Application/Dtos/System/CreateCountryInput.cs
Normal 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;
|
||||||
|
}
|
||||||
33
src/Oms.Application/Dtos/System/UpdateCountryInput.cs
Normal file
33
src/Oms.Application/Dtos/System/UpdateCountryInput.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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 });
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
142
src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs
Normal file
142
src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,33 +152,42 @@ 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("客户不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
var balanceMutator = LazyServiceProvider.GetRequiredService<CustomerBalanceMutator>();
|
if (payAmount > 0)
|
||||||
await balanceMutator.DebitOrderPayAsync(
|
{
|
||||||
customer,
|
var balanceMutator = LazyServiceProvider.GetRequiredService<CustomerBalanceMutator>();
|
||||||
payAmount,
|
await balanceMutator.DebitOrderPayAsync(
|
||||||
order.Id,
|
customer,
|
||||||
order.ServiceNo,
|
payAmount,
|
||||||
$"订单支付 {order.ServiceNo}");
|
order.Id,
|
||||||
|
order.ServiceNo,
|
||||||
if (order.CouponInstanceId.HasValue)
|
$"订单支付 {order.ServiceNo}");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
|
||||||
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>
|
||||||
|
|||||||
@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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)
|
||||||
{
|
|
||||||
Id = x.Id,
|
|
||||||
ChineseName = x.ChineseName,
|
|
||||||
EnglishName = x.EnglishName,
|
|
||||||
CountryCode = x.CountryCode
|
|
||||||
})
|
|
||||||
.ToList();
|
.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,
|
||||||
|
ChineseName = x.ChineseName,
|
||||||
|
EnglishName = x.EnglishName,
|
||||||
|
CountryCode = x.CountryCode,
|
||||||
|
CreateTime = x.CreateTime,
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
179
src/Oms.Application/Shared/CountryImportHelper.cs
Normal file
179
src/Oms.Application/Shared/CountryImportHelper.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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("优惠券已被其他订单占用");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user