From dd1318bc00398c616fee6007bc4f6f0e0a1698ec Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Tue, 22 Sep 2026 16:59:33 +0800 Subject: [PATCH 01/41] =?UTF-8?q?feat=EF=BC=9A=E5=AF=B9=E6=8E=A5fake=20oss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 49 +- Directory.Build.props | 2 +- .../Labeling/LabelPdfGenerationService.cs | 5 +- src/Oms.Application/Oms.Application.csproj | 3 +- src/Oms.Application/OmsApplicationModule.cs | 3 +- .../Services/Orders/OrderFileManager.cs | 9 +- .../Services/Orders/OrderService.Helpers.cs | 8 +- .../Services/Orders/OrderService.StockBind.cs | 1 + .../Services/Orders/OrderService.cs | 5 +- .../Services/Rbac/OmsUserService.cs | 9 +- .../Services/Recharge/RechargeService.cs | 3 +- .../Services/Storage/FileStorageService.cs | 161 ------ .../Warehouse/WarehouseService.Parcel.cs | 2 +- .../Warehouse/WarehouseService.Sku.cs | 7 +- .../Services/Xhs/XhsMediaService.cs | 5 +- .../Shared/OmsFileUploadHelper.cs | 29 +- .../Storage/DirectUploadTickets.cs | 50 -- .../Storage/FileStorageOptions.cs | 66 --- .../Storage/FileStorageServiceExtensions.cs | 93 ---- .../Storage/FileStorageUploadPaths.cs | 59 --- src/Oms.Application/Storage/IFileStorage.cs | 54 -- .../Storage/LocalFileStorage.cs | 171 ------ .../Storage/OmsObjectStorageExtensions.cs | 86 ++++ .../Storage/TencentCosFileStorage.cs | 487 ------------------ src/SimpleAdmin.Api/Auth/AuthService.cs | 5 +- src/SimpleAdmin.Api/SimpleAdmin.Api.csproj | 3 + src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 23 +- .../Storage/ObjectStorageStartupLogger.cs | 36 ++ .../appsettings.Development.json | 42 +- 29 files changed, 263 insertions(+), 1213 deletions(-) delete mode 100644 src/Oms.Application/Services/Storage/FileStorageService.cs delete mode 100644 src/Oms.Application/Storage/DirectUploadTickets.cs delete mode 100644 src/Oms.Application/Storage/FileStorageOptions.cs delete mode 100644 src/Oms.Application/Storage/FileStorageServiceExtensions.cs delete mode 100644 src/Oms.Application/Storage/FileStorageUploadPaths.cs delete mode 100644 src/Oms.Application/Storage/IFileStorage.cs delete mode 100644 src/Oms.Application/Storage/LocalFileStorage.cs create mode 100644 src/Oms.Application/Storage/OmsObjectStorageExtensions.cs delete mode 100644 src/Oms.Application/Storage/TencentCosFileStorage.cs create mode 100644 src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 9d38b61..e2f0a9a 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,10 +1,10 @@ -# 复制到 admin .gitea/workflows/deploy.yml +# 复制到 admin .gitea/workflows/deploy.yml +# push master → prod;push test → test name: Build and Deploy on: push: - branches: [master, main] - workflow_dispatch: + branches: [master, test] env: CSPROJ: src/SimpleAdmin.Api/SimpleAdmin.Api.csproj @@ -21,14 +21,47 @@ jobs: - name: Build and deploy run: | set -euxo pipefail - export KUBECONFIG=/etc/rancher/k3s/k3s.yaml - TAG="${{ gitea.sha }}" - IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}" + REF="${{ gitea.ref_name }}" + case "$REF" in + test) TARGET=test ;; + master) TARGET=prod ;; + *) echo "unsupported branch: $REF"; exit 1 ;; + esac + + SHA="${{ gitea.sha }}" REPO="${{ gitea.repository }}" test -n "$REPO" && [[ "$REPO" == */* ]] - echo "REPO=$REPO TAG=$TAG host=$(hostname)" + + case "$TARGET" in + test) TAG="test-${SHA}" ;; + *) TAG="${SHA}" ;; + esac + + IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}" + echo "TARGET=$TARGET REF=$REF REPO=$REPO TAG=$TAG host=$(hostname)" + + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + unset NAMESPACE cd /data/k3s ./scripts/ci-publish-container.sh "$REPO" "${{ env.CSPROJ }}" \ - "${{ env.IMAGE_NAME }}" "$TAG" "$TAG" + "${{ env.IMAGE_NAME }}" "$TAG" "$SHA" + + case "$TARGET" in + test) + export KUBECONFIG=/etc/rancher/k3s/k3s-test.yaml + export NAMESPACE=test + export MANIFEST_ROOT=/data/k3s-test + ;; + *) + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + export NAMESPACE=prod + export MANIFEST_ROOT=/data/k3s + ;; + esac + if [[ ! -f "$KUBECONFIG" ]]; then + echo "FAIL: missing KUBECONFIG=$KUBECONFIG host=$(hostname) uid=$(id -u)" + ls -la /etc/rancher/k3s/ /root/.kube/ 2>/dev/null || true + exit 1 + fi ./scripts/ci-deploy-api.sh "${{ env.DEPLOY_NAME }}" \ "${{ env.CONTAINER_NAME }}" "$IMG" diff --git a/Directory.Build.props b/Directory.Build.props index e7da47d..7d6f04d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 8.0.5.16 + 8.0.5.17 diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs index 8b3516b..a6da67b 100644 --- a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs +++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs @@ -1,4 +1,5 @@ using Fake.Application; +using Fake.ObjectStorage; using Microsoft.Extensions.Logging; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; @@ -22,7 +23,7 @@ public sealed class LabelPdfGenerationService( ISystemOtherConfigRepository systemOtherConfigRepository, IOrderRepository orderRepository, OrderFileManager orderFileManager, - IFileStorage fileStorage, + IObjectStorage fileStorage, ILogger logger) { public async Task GenerateAndPersistAsync( @@ -167,7 +168,7 @@ public sealed class LabelPdfGenerationService( var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf"; var relativeKey = $"uploads/prints/orders/{fileName}"; await using var stream = new MemoryStream(pdfBytes); - return await fileStorage.SaveAsync(stream, relativeKey, "application/pdf", cancellationToken); + return await fileStorage.SaveObjectAsync(stream, relativeKey, "application/pdf", cancellationToken); } } diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index 8783a7d..73214d1 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -20,6 +20,7 @@ + @@ -29,8 +30,6 @@ - - diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs index 7603018..acd9b8a 100644 --- a/src/Oms.Application/OmsApplicationModule.cs +++ b/src/Oms.Application/OmsApplicationModule.cs @@ -13,7 +13,6 @@ using Oms.Application.Integrations.PlatformShop; using Oms.Application.Labeling; using Oms.Application.Labeling.Hiprint; using Oms.Application.Shared; -using Oms.Application.Storage; using Oms.Application.Workers; using Oms.Domain; @@ -21,6 +20,7 @@ namespace Oms.Application; /// /// OMS 应用层模块注册。 +/// 对象存储由 Api 宿主注册 Fake.ObjectStorage(Local / TencentCos);文件元数据走 Fake.FileManagement。 /// [DependsOn( typeof(OmsDomainModule), @@ -52,7 +52,6 @@ public class OmsApplicationModule : FakeModule context.Services.AddTransient(); context.Services.AddTransient(); - context.Services.AddOmsFileStorage(configuration); context.Services.AddOmsSmallBagTrack(configuration); context.Services.AddOmsItfxIntegration(configuration); context.Services.AddOmsPlatformShopIntegration(configuration); diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs index b4ae163..93c8fc5 100644 --- a/src/Oms.Application/Services/Orders/OrderFileManager.cs +++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs @@ -1,5 +1,6 @@ using Fake.Application; using Fake.DependencyInjection; +using Fake.ObjectStorage; using Microsoft.AspNetCore.Http; using Oms.Application.Dtos.Orders; using Oms.Application.Storage; @@ -14,7 +15,7 @@ namespace Oms.Application.Services.Orders; /// public sealed class OrderFileManager( IOrderFileRepository orderFileRepository, - IFileStorage fileStorage) : IScopedDependency + IObjectStorage fileStorage) : IScopedDependency { private static readonly HashSet ImageExt = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" }; @@ -94,7 +95,7 @@ public sealed class OrderFileManager( OrderId = orderId, FileType = OrderFileTypeCodes.SystemLabel, FileName = fileName, - FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(), + FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(), ContentType = contentType, FileSize = fileSize, Sort = 0, @@ -143,7 +144,7 @@ public sealed class OrderFileManager( OrderId = orderId, FileType = OrderFileTypeCodes.DownstreamLabel, FileName = TryFileNameFromUrl(fileUrl), - FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(), + FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(), ContentType = null, Sort = existing.Count, Source = OrderFileSourceCodes.Downstream, @@ -305,7 +306,7 @@ public sealed class OrderFileManager( var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}"; await using var stream = file.OpenReadStream(); - var key = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType); + var key = await fileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType); return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length); } diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index fc27a3d..63a6b9c 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -488,12 +488,12 @@ public partial class OrderService throw new BusinessException("自定义模板样例文件不存在,请重新上传"); } - if (!await FileStorage.ExistsAsync(template.SourceFilePath)) + if (!await FileStorage.ExistsAsync(FileStoragePath.NormalizeKey(template.SourceFilePath))) { throw new BusinessException("自定义模板样例文件不存在,请重新上传"); } - await using var stream = await FileStorage.OpenReadAsync(template.SourceFilePath); + await using var stream = await FileStorage.GetAsync(FileStoragePath.NormalizeKey(template.SourceFilePath)); using var ms = new MemoryStream(); await stream.CopyToAsync(ms); var bytes = ms.ToArray(); @@ -628,7 +628,7 @@ public partial class OrderService """; var relativeKey = $"uploads/prints/orders/{fileName}"; await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(html)); - var key = await FileStorage.SaveAsync(stream, relativeKey, "text/html; charset=utf-8"); + var key = await FileStorage.SaveObjectAsync(stream, relativeKey, "text/html; charset=utf-8"); return FileStorage.GetPublicUrl(key); } @@ -647,7 +647,7 @@ public partial class OrderService private async Task CleanupExpiredLocalExportFilesAsync() { // COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。 - if (FileStorage is not LocalFileStorage) + if (!FileStorage.IsLocalProvider()) { return; } diff --git a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs index 09ec87b..04b0668 100644 --- a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs +++ b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; +using Oms.Application.Storage; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 91bff8f..a7c7043 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -1,5 +1,6 @@ using Fake.Application; using Fake.Domain.Exceptions; +using Fake.ObjectStorage; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using MiniExcelLibs; @@ -35,7 +36,7 @@ public partial class OrderService : ApplicationService private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService(); private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService(); private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService(); - private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); + private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService(); private LabelPdfGenerationService LabelPdfGenerationService => LazyServiceProvider.GetRequiredService(); private OmsRbacUserDisplayNameResolver UserDisplayNameResolver => @@ -345,7 +346,7 @@ public partial class OrderService : ApplicationService await using var stream = new MemoryStream(); await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders)); stream.Position = 0; - var storageKey = await FileStorage.SaveAsync( + var storageKey = await FileStorage.SaveObjectAsync( stream, relativeKey, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs index 45ae3aa..d4f4a2e 100644 --- a/src/Oms.Application/Services/Rbac/OmsUserService.cs +++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs @@ -6,6 +6,7 @@ using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Fake.ObjectStorage; using Oms.Application.Dtos.Rbac; using Oms.Application.Rbac; using Oms.Application.Storage; @@ -24,7 +25,7 @@ public class OmsUserService( UserService userService, UserProfileRepository profileRepository, IEmployeeRepository employeeRepository, - IFileStorage fileStorage) : ApplicationService + IObjectStorage fileStorage) : ApplicationService { /// /// 获取用户详情(含扩展资料)。 @@ -98,7 +99,7 @@ public class OmsUserService( Account = input.Account, Password = input.Password, Email = input.Email, - Avatar = fileStorage.ToStorageKey(input.Avatar), + Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), OrganizationId = input.OrganizationId, RoleIds = input.RoleIds, }, cancellationToken); @@ -136,7 +137,7 @@ public class OmsUserService( { Name = input.Name, Email = input.Email, - Avatar = fileStorage.ToStorageKey(input.Avatar), + Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), OrganizationId = input.OrganizationId, }, cancellationToken); @@ -174,7 +175,7 @@ public class OmsUserService( [FromQuery] Guid id, [FromQuery] string avatarUrl, CancellationToken cancellationToken = default) => - userService.UpdateAvatarAsync(id, fileStorage.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken); + userService.UpdateAvatarAsync(id, OmsObjectStorageExtensions.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken); /// /// 删除用户。 diff --git a/src/Oms.Application/Services/Recharge/RechargeService.cs b/src/Oms.Application/Services/Recharge/RechargeService.cs index 0479431..200f43a 100644 --- a/src/Oms.Application/Services/Recharge/RechargeService.cs +++ b/src/Oms.Application/Services/Recharge/RechargeService.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using MiniExcelLibs; using Oms.Application.Dtos.Recharge; using Oms.Application.Dtos.Shared; +using Fake.ObjectStorage; using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Domain.CustomersAggregate.Repositories; @@ -22,7 +23,7 @@ public class RechargeService : ApplicationService { private IRechargeRecordRepository RechargeRecordRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); - private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); + private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService(); /// /// 提交充值申请。 diff --git a/src/Oms.Application/Services/Storage/FileStorageService.cs b/src/Oms.Application/Services/Storage/FileStorageService.cs deleted file mode 100644 index f2d91b0..0000000 --- a/src/Oms.Application/Services/Storage/FileStorageService.cs +++ /dev/null @@ -1,161 +0,0 @@ -using Fake.Application; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Oms.Application.Storage; - -namespace Oms.Application.Services.Storage; - -/// -/// 文件存储直传 / 临时访问。私有桶:前端拿凭证直传 COS,后端只存 key。 -/// -[Authorize] -[ApiExplorerSettings(GroupName = "OMS-Storage")] -public class FileStorageService : ApplicationService -{ - private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); - - /// - /// 是否支持前端直传(Cos=true)。 - /// - [HttpGet("/file-storage/capabilities")] - public object GetCapabilities() => new - { - supportsClientDirectUpload = FileStorage.SupportsClientDirectUpload, - provider = FileStorage.GetType().Name - }; - - /// - /// 签发预签名 PUT 上传地址。前端对该 URL 直接 PUT 文件体,成功后把返回的 key 交给业务接口落库。 - /// - [HttpPost("/file-storage/presign-upload")] - public async Task PresignUploadAsync([FromBody] PresignUploadInput input) - { - if (string.IsNullOrWhiteSpace(input.FileName)) - { - throw new BusinessException("fileName 不能为空"); - } - - var ticket = await FileStorage.CreatePresignedUploadAsync( - input.Folder ?? "misc", - input.FileName.Trim(), - input.ContentType); - - return new PresignUploadOutput - { - Key = ticket.Key, - UploadUrl = ticket.UploadUrl, - Method = ticket.Method, - ContentType = ticket.ContentType, - Headers = ticket.Headers, - ExpireAt = ticket.ExpireAt - }; - } - - /// - /// 签发 STS 临时密钥。前端用 cos-js-sdk-v5 等 SDK 直传;落库只存 key。 - /// - [HttpPost("/file-storage/sts-upload")] - public async Task StsUploadAsync([FromBody] PresignUploadInput input) - { - if (string.IsNullOrWhiteSpace(input.FileName)) - { - throw new BusinessException("fileName 不能为空"); - } - - var ticket = await FileStorage.CreateStsUploadAsync( - input.Folder ?? "misc", - input.FileName.Trim()); - - return new StsUploadOutput - { - Key = ticket.Key, - CosKey = ticket.CosKey, - Bucket = ticket.Bucket, - Region = ticket.Region, - TmpSecretId = ticket.TmpSecretId, - TmpSecretKey = ticket.TmpSecretKey, - SessionToken = ticket.SessionToken, - StartTime = ticket.StartTime, - ExpiredTime = ticket.ExpiredTime - }; - } - - /// - /// 按库内 key 生成临时访问地址(私有桶预签名 GET)。 - /// - [HttpGet("/file-storage/access-url")] - public AccessUrlOutput GetAccessUrl([FromQuery] string key) - { - if (string.IsNullOrWhiteSpace(key)) - { - throw new BusinessException("key 不能为空"); - } - - var storageKey = FileStorage.ToStorageKey(key) ?? key.Trim(); - return new AccessUrlOutput - { - Key = storageKey, - Url = FileStorage.GetPublicUrl(storageKey) - }; - } - - /// - /// 将完整 URL / 相对路径规范为库内 key(业务保存前可调用)。 - /// - [HttpPost("/file-storage/normalize-key")] - public NormalizeKeyOutput NormalizeKey([FromBody] NormalizeKeyInput input) - { - var key = FileStorage.ToStorageKey(input.PathOrUrl); - return new NormalizeKeyOutput { Key = key }; - } -} - -public class PresignUploadInput -{ - /// 业务目录:avatars / sku / parcel / recharge / order-files / xhs 等。 - public string? Folder { get; set; } - - public string FileName { get; set; } = string.Empty; - - public string? ContentType { get; set; } -} - -public class PresignUploadOutput -{ - public string Key { get; set; } = string.Empty; - public string UploadUrl { get; set; } = string.Empty; - public string Method { get; set; } = "PUT"; - public string? ContentType { get; set; } - public IReadOnlyDictionary Headers { get; set; } = - new Dictionary(); - public DateTime ExpireAt { get; set; } -} - -public class StsUploadOutput -{ - public string Key { get; set; } = string.Empty; - public string CosKey { get; set; } = string.Empty; - public string Bucket { get; set; } = string.Empty; - public string Region { get; set; } = string.Empty; - public string TmpSecretId { get; set; } = string.Empty; - public string TmpSecretKey { get; set; } = string.Empty; - public string SessionToken { get; set; } = string.Empty; - public long StartTime { get; set; } - public long ExpiredTime { get; set; } -} - -public class AccessUrlOutput -{ - public string Key { get; set; } = string.Empty; - public string Url { get; set; } = string.Empty; -} - -public class NormalizeKeyInput -{ - public string? PathOrUrl { get; set; } -} - -public class NormalizeKeyOutput -{ - public string? Key { get; set; } -} diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs index 156cc0e..af27b0f 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs @@ -178,7 +178,7 @@ public partial class WarehouseService throw new BusinessException("商品数量必须大于 0"); } - var imageUrl = FileStorage.ToStorageKey(itemInput.ImageUrl); + var imageUrl = OmsObjectStorageExtensions.ToStorageKey(itemInput.ImageUrl); if (imageUrl != null && imageUrl.Length > 1024) { imageUrl = imageUrl[..1024]; diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs index 4f16e2e..6a87085 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Warehouse; +using Fake.ObjectStorage; using Oms.Application.Shared; using Oms.Application.Storage; @@ -11,8 +12,8 @@ namespace Oms.Application.Services.Warehouse; public partial class WarehouseService { - private IFileStorage FileStorage => - LazyServiceProvider.GetRequiredService(); + private IObjectStorage FileStorage => + LazyServiceProvider.GetRequiredService(); /// /// 查询 SKU 列表。 @@ -117,7 +118,7 @@ public partial class WarehouseService entity.SkuCode = skuCode; entity.SkuName = skuName; entity.SkuNameEn = string.IsNullOrWhiteSpace(input.SkuNameEn) ? null : input.SkuNameEn.Trim(); - entity.SkuImageUrl = FileStorage.ToStorageKey(input.SkuImageUrl); + entity.SkuImageUrl = OmsObjectStorageExtensions.ToStorageKey(input.SkuImageUrl); entity.UnitPrice = input.UnitPrice; entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim(); entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim(); diff --git a/src/Oms.Application/Services/Xhs/XhsMediaService.cs b/src/Oms.Application/Services/Xhs/XhsMediaService.cs index c4feb9c..d22ff8f 100644 --- a/src/Oms.Application/Services/Xhs/XhsMediaService.cs +++ b/src/Oms.Application/Services/Xhs/XhsMediaService.cs @@ -1,4 +1,5 @@ using Fake.Application; +using Fake.ObjectStorage; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Xhs; @@ -15,7 +16,7 @@ public class XhsMediaService : ApplicationService { private IXhsMediaRepository MediaRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); - private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); + private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService(); /// /// 上传媒体文件 @@ -34,7 +35,7 @@ public class XhsMediaService : ApplicationService var relativeKey = $"uploads/xhs/{customerId}/{storedName}"; await using var stream = file.OpenReadStream(); - var storageKey = await FileStorage.SaveAsync(stream, relativeKey, file.ContentType); + var storageKey = await FileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType); var publicUrl = FileStorage.GetPublicUrl(storageKey); var media = new XhsMedia diff --git a/src/Oms.Application/Shared/OmsFileUploadHelper.cs b/src/Oms.Application/Shared/OmsFileUploadHelper.cs index 0c479fc..157cec9 100644 --- a/src/Oms.Application/Shared/OmsFileUploadHelper.cs +++ b/src/Oms.Application/Shared/OmsFileUploadHelper.cs @@ -1,12 +1,14 @@ using Fake.Application; +using Fake.ObjectStorage; using Microsoft.AspNetCore.Http; using Oms.Application.Storage; namespace Oms.Application.Shared; /// -/// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。 -/// 返回值为 object key,对外展示请再调用 。 +/// OMS 上传文件辅助(经 )。 +/// 返回值为 object key,对外展示请再调用 。 +/// 用户直传请走 Fake.FileManagement(Presign/Sts + Confirm → FileId)。 /// public static class OmsFileUploadHelper { @@ -15,27 +17,18 @@ public static class OmsFileUploadHelper private const long MaxImageBytes = 5 * 1024 * 1024; - /// - /// 保存充值截图,返回可访问 URL。 - /// - public static Task SaveRechargeScreenshotAsync(IFormFile? file, IFileStorage storage) => + public static Task SaveRechargeScreenshotAsync(IFormFile? file, IObjectStorage storage) => SaveImageAsync(file, storage, "recharge", "充值截图"); - /// - /// 保存仓储 SKU 图片。 - /// - public static Task SaveWarehouseSkuImageAsync(IFormFile file, IFileStorage storage) => + public static Task SaveWarehouseSkuImageAsync(IFormFile file, IObjectStorage storage) => SaveImageAsync(file, storage, "sku", "SKU 图片"); - /// - /// 保存包裹商品图片。 - /// - public static Task SaveWarehouseParcelImageAsync(IFormFile file, IFileStorage storage) => + public static Task SaveWarehouseParcelImageAsync(IFormFile file, IObjectStorage storage) => SaveImageAsync(file, storage, "parcel", "包裹商品图片"); private static async Task SaveImageAsync( IFormFile? file, - IFileStorage storage, + IObjectStorage storage, string folder, string label) { @@ -70,7 +63,7 @@ public static class OmsFileUploadHelper var relativeKey = $"uploads/{folder}/{monthFolder}/{fileName}"; await using var stream = file.OpenReadStream(); - return await storage.SaveAsync(stream, relativeKey, file.ContentType); + return await storage.SaveObjectAsync(stream, relativeKey, file.ContentType); } private static readonly HashSet ExcelExtensions = @@ -84,7 +77,7 @@ public static class OmsFileUploadHelper public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync( IFormFile file, int customerId, - IFileStorage storage) + IObjectStorage storage) { if (file == null || file.Length <= 0) { @@ -106,7 +99,7 @@ public static class OmsFileUploadHelper var relativeKey = $"uploads/order-templates/{customerId}/{fileName}"; await using var stream = file.OpenReadStream(); - var key = await storage.SaveAsync( + var key = await storage.SaveObjectAsync( stream, relativeKey, file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); diff --git a/src/Oms.Application/Storage/DirectUploadTickets.cs b/src/Oms.Application/Storage/DirectUploadTickets.cs deleted file mode 100644 index 7b455c1..0000000 --- a/src/Oms.Application/Storage/DirectUploadTickets.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace Oms.Application.Storage; - -/// -/// 预签名 PUT 直传凭证(前端直接 PUT 到 COS,文件不经后端)。 -/// -public sealed class PresignedUploadTicket -{ - /// 库内应保存的逻辑 key(不含 KeyPrefix)。 - public string Key { get; init; } = string.Empty; - - /// 一次性 PUT 地址(含签名)。 - public string UploadUrl { get; init; } = string.Empty; - - public string Method { get; init; } = "PUT"; - - /// PUT 时必须带上的 Content-Type(若签发时绑定了则必填)。 - public string? ContentType { get; init; } - - /// PUT 时必须原样携带的请求头。 - public IReadOnlyDictionary Headers { get; init; } = - new Dictionary(StringComparer.OrdinalIgnoreCase); - - public DateTime ExpireAt { get; init; } -} - -/// -/// STS 临时密钥直传凭证(前端用 cos-js-sdk 等上传)。 -/// -public sealed class StsUploadTicket -{ - /// 库内应保存的逻辑 key。 - public string Key { get; init; } = string.Empty; - - /// COS 对象完整键(含 KeyPrefix),SDK putObject 用。 - public string CosKey { get; init; } = string.Empty; - - public string Bucket { get; init; } = string.Empty; - - public string Region { get; init; } = string.Empty; - - public string TmpSecretId { get; init; } = string.Empty; - - public string TmpSecretKey { get; init; } = string.Empty; - - public string SessionToken { get; init; } = string.Empty; - - public long StartTime { get; init; } - - public long ExpiredTime { get; init; } -} diff --git a/src/Oms.Application/Storage/FileStorageOptions.cs b/src/Oms.Application/Storage/FileStorageOptions.cs deleted file mode 100644 index 9a4279c..0000000 --- a/src/Oms.Application/Storage/FileStorageOptions.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace Oms.Application.Storage; - -/// -/// 文件存储配置。Provider=Local 落盘 wwwroot;Provider=Cos 走腾讯云 COS。 -/// -public class FileStorageOptions -{ - public const string SectionName = "FileStorage"; - - /// - /// 存储提供方:Local | Cos。 - /// - public string Provider { get; set; } = "Local"; - - /// - /// 腾讯云 COS 配置(Provider=Cos 时必填)。 - /// - public TencentCosOptions Cos { get; set; } = new(); -} - -/// -/// 腾讯云 COS 连接参数。 -/// -public class TencentCosOptions -{ - /// API 密钥 SecretId。 - public string SecretId { get; set; } = string.Empty; - - /// API 密钥 SecretKey。 - public string SecretKey { get; set; } = string.Empty; - - /// 地域,如 ap-guangzhou。 - public string Region { get; set; } = string.Empty; - - /// 存储桶,格式 bucketname-APPID。 - public string Bucket { get; set; } = string.Empty; - - /// - /// 对外访问根地址。可填 CDN / 自定义域名,末尾不要斜杠。 - /// 空则默认 https://{Bucket}.cos.{Region}.myqcloud.com - /// - public string? BaseUrl { get; set; } - - /// - /// 对象键前缀(如 oms/prod/),可空。 - /// - public string KeyPrefix { get; set; } = string.Empty; - - /// - /// 预签名读 URL 有效期(秒)。私有桶临时访问用。 - /// - public long DurationSecond { get; set; } = 3600; - - /// - /// 预签名上传 / STS 有效期(秒)。建议短于读有效期。 - /// - public long UploadDurationSecond { get; set; } = 600; - - /// - /// 是否对 GetPublicUrl 返回预签名链接。私有桶必须为 true。 - /// - public bool SignUrls { get; set; } = true; - - /// 是否 HTTPS。 - public bool IsHttps { get; set; } = true; -} diff --git a/src/Oms.Application/Storage/FileStorageServiceExtensions.cs b/src/Oms.Application/Storage/FileStorageServiceExtensions.cs deleted file mode 100644 index fb38e7f..0000000 --- a/src/Oms.Application/Storage/FileStorageServiceExtensions.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Oms.Application.Storage; - -public static class FileStorageServiceExtensions -{ - public static IServiceCollection AddOmsFileStorage(this IServiceCollection services, IConfiguration configuration) - { - var section = configuration.GetSection(FileStorageOptions.SectionName); - services.Configure(section); - - var options = section.Get() ?? new FileStorageOptions(); - var provider = ResolveProvider(options); - - if (string.Equals(provider, "Cos", StringComparison.OrdinalIgnoreCase)) - { - services.AddSingleton(); - } - else - { - services.AddSingleton(); - } - - services.AddHostedService(); - return services; - } - - /// - /// 显式 Provider 优先;未写 Provider 但 Cos 凭证齐全时自动走 Cos,避免生产误落 Local。 - /// - internal static string ResolveProvider(FileStorageOptions options) - { - var configured = options.Provider?.Trim(); - if (!string.IsNullOrWhiteSpace(configured)) - { - return configured; - } - - return HasCompleteCos(options.Cos) ? "Cos" : "Local"; - } - - internal static bool HasCompleteCos(TencentCosOptions? cos) => - cos != null - && !string.IsNullOrWhiteSpace(cos.SecretId) - && !string.IsNullOrWhiteSpace(cos.SecretKey) - && !string.IsNullOrWhiteSpace(cos.Region) - && !string.IsNullOrWhiteSpace(cos.Bucket) - && !cos.Bucket.Contains("your-bucket", StringComparison.OrdinalIgnoreCase); -} - -/// -/// 启动时打印实际 IFileStorage 实现,便于核对 Consul 是否生效。 -/// -internal sealed class FileStorageStartupLogger( - IFileStorage storage, - IOptions options, - ILogger logger) : IHostedService -{ - public Task StartAsync(CancellationToken cancellationToken) - { - var cfg = options.Value; - var cosReady = FileStorageServiceExtensions.HasCompleteCos(cfg.Cos); - logger.LogInformation( - "IFileStorage 实际类型={Implementation},配置 Provider={ConfiguredProvider},Cos凭证齐全={CosReady},Section={Section}", - storage.GetType().FullName, - string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider, - cosReady, - FileStorageOptions.SectionName); - - if (storage is LocalFileStorage && cosReady) - { - logger.LogWarning( - "已检测到齐全 Cos 凭证,但仍在使用 LocalFileStorage。请确认 Consul 中 {Section}:Provider 是否为 Cos(当前={ConfiguredProvider})", - FileStorageOptions.SectionName, - string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider); - } - - if (storage is LocalFileStorage && !cosReady) - { - logger.LogWarning( - "当前为 Local 存储。生产若要用 Cos,请在 Consul KV(idwms/admin)配置 {Section}:Provider=Cos 及 Cos.SecretId/SecretKey/Region/Bucket", - FileStorageOptions.SectionName); - } - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} diff --git a/src/Oms.Application/Storage/FileStorageUploadPaths.cs b/src/Oms.Application/Storage/FileStorageUploadPaths.cs deleted file mode 100644 index 5410a72..0000000 --- a/src/Oms.Application/Storage/FileStorageUploadPaths.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Fake.Application; - -namespace Oms.Application.Storage; - -/// -/// 直传目录与 object key 生成。 -/// -public static class FileStorageUploadPaths -{ - private static readonly HashSet AllowedFolders = new(StringComparer.OrdinalIgnoreCase) - { - "avatars", - "sku", - "parcel", - "recharge", - "order-files", - "order-templates", - "xhs", - "prints", - "exports", - "misc" - }; - - public static string NormalizeFolder(string? folder) - { - var value = (folder ?? string.Empty).Trim().Trim('/').Replace('\\', '/'); - if (string.IsNullOrWhiteSpace(value)) - { - throw new BusinessException("上传目录 folder 不能为空"); - } - - // 允许 uploads/sku 或 sku - if (value.StartsWith("uploads/", StringComparison.OrdinalIgnoreCase)) - { - value = value["uploads/".Length..]; - } - - var root = value.Split('/', StringSplitOptions.RemoveEmptyEntries)[0]; - if (!AllowedFolders.Contains(root)) - { - throw new BusinessException($"不支持的上传目录: {root}"); - } - - return value; - } - - public static string BuildLogicalKey(string folder, string fileName) - { - var normalizedFolder = NormalizeFolder(folder); - var ext = Path.GetExtension(fileName); - if (string.IsNullOrWhiteSpace(ext) || ext.Length > 16) - { - ext = ".bin"; - } - - var month = DateTime.Now.ToString("yyyyMM"); - return $"uploads/{normalizedFolder}/{month}/{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; - } -} diff --git a/src/Oms.Application/Storage/IFileStorage.cs b/src/Oms.Application/Storage/IFileStorage.cs deleted file mode 100644 index cd6b501..0000000 --- a/src/Oms.Application/Storage/IFileStorage.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace Oms.Application.Storage; - -/// -/// 统一文件存储(本地 / 腾讯云 COS)。 -/// 约定:库内只存 object key(如 uploads/avatars/a.jpg)。 -/// 私有桶场景:访问用临时签名 URL;用户上传用预签名 PUT 或 STS,文件不经业务服务器。 -/// -public interface IFileStorage -{ - /// 是否支持前端直传(COS=true,Local=false)。 - bool SupportsClientDirectUpload { get; } - - /// - /// 服务端落盘/上传(仅用于后端生成的文件,如面单 PDF、导出)。用户上传请走直传。 - /// - Task SaveAsync( - Stream content, - string relativeKey, - string? contentType = null, - CancellationToken cancellationToken = default); - - /// - /// 临时访问地址。Cos 私有桶返回预签名 GET;Local 返回 /uploads/...;外链原样返回。 - /// - string GetPublicUrl(string? keyOrUrl); - - /// - /// 签发前端直传用的预签名 PUT URL。 - /// - Task CreatePresignedUploadAsync( - string folder, - string fileName, - string? contentType = null, - CancellationToken cancellationToken = default); - - /// - /// 签发 STS 临时密钥(前端 COS SDK 直传)。不支持时抛业务异常。 - /// - Task CreateStsUploadAsync( - string folder, - string fileName, - CancellationToken cancellationToken = default); - - /// - /// 将 key / 相对路径 / 完整 URL 规范为库内应保存的 object key。 - /// - string? ToStorageKey(string? keyOrUrl); - - Task OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default); - - Task ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default); - - Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default); -} diff --git a/src/Oms.Application/Storage/LocalFileStorage.cs b/src/Oms.Application/Storage/LocalFileStorage.cs deleted file mode 100644 index b9a54c0..0000000 --- a/src/Oms.Application/Storage/LocalFileStorage.cs +++ /dev/null @@ -1,171 +0,0 @@ -using Fake.Application; -using Microsoft.AspNetCore.Hosting; - -namespace Oms.Application.Storage; - -/// -/// 本地 wwwroot 文件存储。库内存 key,对外为 /key。不支持前端直传 COS。 -/// -public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage -{ - public bool SupportsClientDirectUpload => false; - - public async Task SaveAsync( - Stream content, - string relativeKey, - string? contentType = null, - CancellationToken cancellationToken = default) - { - var key = FileStoragePath.NormalizeKey(relativeKey); - var absolutePath = ToAbsolutePath(key); - var directory = Path.GetDirectoryName(absolutePath); - if (!string.IsNullOrWhiteSpace(directory)) - { - try - { - Directory.CreateDirectory(directory); - } - catch (UnauthorizedAccessException ex) - { - throw new UnauthorizedAccessException( - $"本地文件存储无法写入目录 '{directory}'(WebRoot={GetWebRoot()})。" + - "容器请确保 wwwroot/uploads 可写,或将 Oms:FileStorage:Provider 设为 Cos。", - ex); - } - catch (IOException ex) - { - throw new IOException( - $"本地文件存储无法创建目录 '{directory}'(WebRoot={GetWebRoot()})。" + - "容器请确保 wwwroot/uploads 可写,或将 Oms:FileStorage:Provider 设为 Cos。", - ex); - } - } - - await using var file = new FileStream( - absolutePath, - FileMode.Create, - FileAccess.Write, - FileShare.None, - bufferSize: 81920, - useAsync: true); - if (content.CanSeek) - { - content.Position = 0; - } - - await content.CopyToAsync(file, cancellationToken); - return key; - } - - public string GetPublicUrl(string? keyOrUrl) - { - if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0") - { - return keyOrUrl ?? string.Empty; - } - - if (FileStoragePath.IsAbsoluteUrl(keyOrUrl)) - { - return keyOrUrl; - } - - if (keyOrUrl.StartsWith('/')) - { - return keyOrUrl.Replace('\\', '/'); - } - - return "/" + FileStoragePath.NormalizeKey(keyOrUrl); - } - - public Task CreatePresignedUploadAsync( - string folder, - string fileName, - string? contentType = null, - CancellationToken cancellationToken = default) => - throw new BusinessException( - "当前为 Local 存储,不支持 COS 直传。请将 Oms:FileStorage:Provider 设为 Cos,或继续使用服务端 multipart 上传。"); - - public Task CreateStsUploadAsync( - string folder, - string fileName, - CancellationToken cancellationToken = default) => - throw new BusinessException("当前为 Local 存储,不支持 STS 直传。请将 Oms:FileStorage:Provider 设为 Cos。"); - - public string? ToStorageKey(string? keyOrUrl) - { - if (string.IsNullOrWhiteSpace(keyOrUrl)) - { - return null; - } - - if (keyOrUrl == "0") - { - return "0"; - } - - if (FileStoragePath.IsAbsoluteUrl(keyOrUrl)) - { - return keyOrUrl.Trim(); - } - - return FileStoragePath.NormalizeKey(keyOrUrl); - } - - public Task OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - var absolutePath = ResolveAbsolutePath(pathOrUrl); - if (!File.Exists(absolutePath)) - { - throw new FileNotFoundException("本地文件不存在", absolutePath); - } - - Stream stream = new FileStream( - absolutePath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: 81920, - useAsync: true); - return Task.FromResult(stream); - } - - public Task ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - var absolutePath = ResolveAbsolutePath(pathOrUrl); - return Task.FromResult(File.Exists(absolutePath)); - } - - public Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - var absolutePath = ResolveAbsolutePath(pathOrUrl); - if (File.Exists(absolutePath)) - { - File.Delete(absolutePath); - } - - return Task.CompletedTask; - } - - private string ResolveAbsolutePath(string pathOrUrl) - { - if (string.IsNullOrWhiteSpace(pathOrUrl)) - { - throw new ArgumentException("路径不能为空", nameof(pathOrUrl)); - } - - if (Path.IsPathRooted(pathOrUrl) && File.Exists(pathOrUrl)) - { - return pathOrUrl; - } - - return ToAbsolutePath(FileStoragePath.NormalizeKey(pathOrUrl)); - } - - private string ToAbsolutePath(string relativeKey) => - Path.Combine(GetWebRoot(), relativeKey.Replace('/', Path.DirectorySeparatorChar)); - - private string GetWebRoot() => - string.IsNullOrWhiteSpace(environment.WebRootPath) - ? Path.Combine(environment.ContentRootPath, "wwwroot") - : environment.WebRootPath; -} diff --git a/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs b/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs new file mode 100644 index 0000000..ab34314 --- /dev/null +++ b/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs @@ -0,0 +1,86 @@ +using Fake.ObjectStorage; + +namespace Oms.Application.Storage; + +/// +/// OMS 对 的约定封装:库内存逻辑 object key(或外链原样),展示用临时/公开 URL。 +/// +public static class OmsObjectStorageExtensions +{ + /// + /// 将 key / 相对路径规范为库内 object key;绝对外链原样返回;空返回 null。 + /// + public static string? ToStorageKey(string? keyOrUrl) + { + if (string.IsNullOrWhiteSpace(keyOrUrl)) + { + return null; + } + + var raw = keyOrUrl.Trim(); + if (raw == "0") + { + return "0"; + } + + if (FileStoragePath.IsAbsoluteUrl(raw)) + { + return raw; + } + + return FileStoragePath.NormalizeKey(raw); + } + + /// + /// 临时访问地址。空值返回空串;外链原样返回;逻辑 key 走 。 + /// + public static async Task GetPublicUrlAsync( + this IObjectStorage storage, + string? keyOrUrl, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0") + { + return string.Empty; + } + + var raw = keyOrUrl.Trim(); + if (FileStoragePath.IsAbsoluteUrl(raw)) + { + return raw; + } + + var key = FileStoragePath.NormalizeKey(raw); + return await storage.GetUrlAsync(key, cancellationToken: cancellationToken); + } + + /// + /// 同步取访问 URL(映射 DTO 等场景)。 + /// + public static string GetPublicUrl(this IObjectStorage storage, string? keyOrUrl) => + storage.GetPublicUrlAsync(keyOrUrl).ConfigureAwait(false).GetAwaiter().GetResult(); + + /// + /// 服务端写入对象,返回逻辑 object key。 + /// + public static async Task SaveObjectAsync( + this IObjectStorage storage, + Stream content, + string relativeKey, + string? contentType = null, + CancellationToken cancellationToken = default) + { + var result = await storage.SaveAsync( + new ObjectStorageSaveArgs + { + ObjectKey = FileStoragePath.NormalizeKey(relativeKey), + Content = content, + ContentType = contentType + }, + cancellationToken); + return result.ObjectKey; + } + + public static bool IsLocalProvider(this IObjectStorage storage) => + string.Equals(storage.Name, "Local", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Oms.Application/Storage/TencentCosFileStorage.cs b/src/Oms.Application/Storage/TencentCosFileStorage.cs deleted file mode 100644 index 9f152d7..0000000 --- a/src/Oms.Application/Storage/TencentCosFileStorage.cs +++ /dev/null @@ -1,487 +0,0 @@ -using COSSTS; -using COSXML; -using COSXML.Auth; -using COSXML.Model.Object; -using COSXML.Model.Tag; -using Fake.Application; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Oms.Application.Storage; - -/// -/// 腾讯云 COS:库内存逻辑 key;读返回预签名 GET;写支持预签名 PUT / STS 前端直传。 -/// -public sealed class TencentCosFileStorage : IFileStorage, IDisposable -{ - private static readonly string[] UploadActions = - [ - "name/cos:PutObject", - "name/cos:PostObject", - "name/cos:InitiateMultipartUpload", - "name/cos:ListMultipartUploads", - "name/cos:ListParts", - "name/cos:UploadPart", - "name/cos:CompleteMultipartUpload", - "name/cos:AbortMultipartUpload" - ]; - - private readonly TencentCosOptions _options; - private readonly CosXmlServer _cosXml; - private readonly ILogger _logger; - private readonly string _publicBaseUrl; - private readonly string _keyPrefix; - private readonly string _appId; - private readonly string? _customHost; - - public TencentCosFileStorage( - IOptions options, - ILogger logger) - { - _logger = logger; - _options = options.Value.Cos ?? new TencentCosOptions(); - ValidateOptions(_options); - - _appId = ExtractAppId(_options.Bucket); - var config = new CosXmlConfig.Builder() - .IsHttps(_options.IsHttps) - .SetRegion(_options.Region) - .SetAppid(_appId) - .Build(); - - var signSeconds = Math.Max(60, Math.Max(_options.DurationSecond, _options.UploadDurationSecond)); - var credential = new DefaultQCloudCredentialProvider( - _options.SecretId, - _options.SecretKey, - signSeconds); - - _cosXml = new CosXmlServer(config, credential); - _publicBaseUrl = ResolvePublicBaseUrl(_options); - _keyPrefix = (_options.KeyPrefix ?? string.Empty).Replace('\\', '/').Trim('/'); - _customHost = Uri.TryCreate(_publicBaseUrl, UriKind.Absolute, out var baseUri) - ? baseUri.Host - : null; - } - - public bool SupportsClientDirectUpload => true; - - public Task SaveAsync( - Stream content, - string relativeKey, - string? contentType = null, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - var logicalKey = FileStoragePath.NormalizeKey(relativeKey); - var objectKey = ToObjectKey(logicalKey); - if (content.CanSeek) - { - content.Position = 0; - } - - Stream uploadStream = content; - MemoryStream? owned = null; - if (!content.CanSeek) - { - owned = new MemoryStream(); - content.CopyTo(owned); - owned.Position = 0; - uploadStream = owned; - } - - try - { - var request = new PutObjectRequest(_options.Bucket, objectKey, uploadStream); - if (!string.IsNullOrWhiteSpace(contentType)) - { - request.SetRequestHeader("Content-Type", contentType); - } - - var result = _cosXml.PutObject(request); - if (result.httpCode is < 200 or >= 300) - { - throw new BusinessException($"上传 COS 失败: HTTP {result.httpCode}"); - } - - _logger.LogDebug("COS 服务端上传成功: {ObjectKey}", objectKey); - return Task.FromResult(logicalKey); - } - finally - { - owned?.Dispose(); - } - } - - public string GetPublicUrl(string? keyOrUrl) - { - if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0") - { - return keyOrUrl ?? string.Empty; - } - - if (FileStoragePath.IsAbsoluteUrl(keyOrUrl)) - { - if (!IsManagedPublicUrl(keyOrUrl)) - { - return keyOrUrl; - } - - // 已是签名 URL:原样返回,避免重复签名 - if (keyOrUrl.Contains("q-signature=", StringComparison.OrdinalIgnoreCase) - || keyOrUrl.Contains("sign=", StringComparison.OrdinalIgnoreCase)) - { - return keyOrUrl; - } - - var logical = StripKeyPrefix(FileStoragePath.NormalizeKey(keyOrUrl)); - return BuildSignedGetUrl(logical); - } - - return BuildSignedGetUrl(keyOrUrl); - } - - public Task CreatePresignedUploadAsync( - string folder, - string fileName, - string? contentType = null, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - var logicalKey = FileStorageUploadPaths.BuildLogicalKey(folder, fileName); - var objectKey = ToObjectKey(logicalKey); - var duration = Math.Max(60, _options.UploadDurationSecond); - var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrWhiteSpace(contentType)) - { - headers["Content-Type"] = contentType.Trim(); - } - - var preSign = new PreSignatureStruct - { - appid = _appId, - bucket = _options.Bucket, - region = _options.Region, - key = objectKey, - httpMethod = "PUT", - isHttps = _options.IsHttps, - signDurationSecond = duration, - headers = headers.Count > 0 ? headers : null, - queryParameters = null - }; - ApplyCustomHost(preSign); - - var uploadUrl = _cosXml.GenerateSignURL(preSign); - return Task.FromResult(new PresignedUploadTicket - { - Key = logicalKey, - UploadUrl = uploadUrl, - Method = "PUT", - ContentType = string.IsNullOrWhiteSpace(contentType) ? null : contentType.Trim(), - Headers = headers, - ExpireAt = DateTime.UtcNow.AddSeconds(duration) - }); - } - - public Task CreateStsUploadAsync( - string folder, - string fileName, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - var logicalKey = FileStorageUploadPaths.BuildLogicalKey(folder, fileName); - var objectKey = ToObjectKey(logicalKey); - var duration = (int)Math.Max(60, _options.UploadDurationSecond); - - var values = new Dictionary - { - ["bucket"] = _options.Bucket, - ["region"] = _options.Region, - ["allowPrefix"] = objectKey, - ["allowActions"] = UploadActions, - ["durationSeconds"] = duration, - ["secretId"] = _options.SecretId, - ["secretKey"] = _options.SecretKey - }; - - Dictionary credential; - try - { - credential = STSClient.genCredential(values); - } - catch (Exception ex) - { - _logger.LogError(ex, "申请 COS STS 临时密钥失败"); - throw new BusinessException("申请 COS 临时上传凭证失败,请检查 SecretId/SecretKey 与 CAM 权限"); - } - - var credentials = ExtractCredentials(credential["Credentials"]); - var startTime = ToUnixSeconds(credential.GetValueOrDefault("StartTime")); - var expiredTime = ToUnixSeconds(credential.GetValueOrDefault("ExpiredTime")); - if (startTime <= 0) - { - startTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - } - - if (expiredTime <= 0) - { - expiredTime = startTime + duration; - } - - return Task.FromResult(new StsUploadTicket - { - Key = logicalKey, - CosKey = objectKey, - Bucket = _options.Bucket, - Region = _options.Region, - TmpSecretId = credentials.tmpSecretId, - TmpSecretKey = credentials.tmpSecretKey, - SessionToken = credentials.token, - StartTime = startTime, - ExpiredTime = expiredTime - }); - } - - public string? ToStorageKey(string? keyOrUrl) - { - if (string.IsNullOrWhiteSpace(keyOrUrl)) - { - return null; - } - - if (keyOrUrl == "0") - { - return "0"; - } - - if (FileStoragePath.IsAbsoluteUrl(keyOrUrl)) - { - if (!IsManagedPublicUrl(keyOrUrl)) - { - return keyOrUrl.Trim(); - } - - var pathKey = FileStoragePath.NormalizeKey(keyOrUrl); - return StripKeyPrefix(pathKey); - } - - return FileStoragePath.NormalizeKey(keyOrUrl); - } - - public Task OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var objectKey = ToObjectKey(pathOrUrl); - var request = new GetObjectBytesRequest(_options.Bucket, objectKey); - var result = _cosXml.GetObject(request); - if (result.httpCode is < 200 or >= 300) - { - throw new FileNotFoundException($"COS 对象不存在或读取失败: {objectKey}"); - } - - Stream stream = new MemoryStream(result.content); - stream.Position = 0; - return Task.FromResult(stream); - } - - public Task ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - var objectKey = ToObjectKey(pathOrUrl); - var request = new HeadObjectRequest(_options.Bucket, objectKey); - var result = _cosXml.HeadObject(request); - return Task.FromResult(result.httpCode is >= 200 and < 300); - } - catch - { - return Task.FromResult(false); - } - } - - public Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - var objectKey = ToObjectKey(pathOrUrl); - var request = new DeleteObjectRequest(_options.Bucket, objectKey); - _cosXml.DeleteObject(request); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "删除 COS 对象失败: {Path}", pathOrUrl); - } - - return Task.CompletedTask; - } - - public void Dispose() - { - } - - private string BuildSignedGetUrl(string logicalOrRelativeKey) - { - var objectKey = ToObjectKey(logicalOrRelativeKey); - if (!_options.SignUrls) - { - _logger.LogWarning( - "Cos.SignUrls=false,私有桶下浏览器将无法直接打开对象。建议保持 SignUrls=true。ObjectKey={ObjectKey}", - objectKey); - return $"{_publicBaseUrl}/{objectKey}"; - } - - try - { - var preSign = new PreSignatureStruct - { - appid = _appId, - bucket = _options.Bucket, - region = _options.Region, - key = objectKey, - httpMethod = "GET", - isHttps = _options.IsHttps, - signDurationSecond = Math.Max(60, _options.DurationSecond), - headers = null, - queryParameters = null - }; - ApplyCustomHost(preSign); - return _cosXml.GenerateSignURL(preSign); - } - catch (Exception ex) - { - _logger.LogError(ex, "生成 COS 预签名 GET 失败: {ObjectKey}", objectKey); - throw new BusinessException("生成临时访问地址失败"); - } - } - - private void ApplyCustomHost(PreSignatureStruct preSign) - { - if (!string.IsNullOrWhiteSpace(_customHost) - && !_customHost.EndsWith(".myqcloud.com", StringComparison.OrdinalIgnoreCase)) - { - preSign.host = _customHost; - preSign.signHost = true; - } - } - - private string ToObjectKey(string pathOrUrl) - { - var key = FileStoragePath.NormalizeKey(pathOrUrl); - if (!string.IsNullOrEmpty(_keyPrefix) - && !key.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase) - && !string.Equals(key, _keyPrefix, StringComparison.OrdinalIgnoreCase)) - { - key = $"{_keyPrefix}/{key}"; - } - - return key; - } - - private string StripKeyPrefix(string objectOrLogicalKey) - { - if (string.IsNullOrEmpty(_keyPrefix)) - { - return objectOrLogicalKey; - } - - if (objectOrLogicalKey.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase)) - { - return objectOrLogicalKey[(_keyPrefix.Length + 1)..]; - } - - if (string.Equals(objectOrLogicalKey, _keyPrefix, StringComparison.OrdinalIgnoreCase)) - { - return string.Empty; - } - - return objectOrLogicalKey; - } - - private bool IsManagedPublicUrl(string url) - { - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) - { - return false; - } - - if (Uri.TryCreate(_publicBaseUrl, UriKind.Absolute, out var baseUri) - && string.Equals(uri.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var defaultHost = $"{_options.Bucket}.cos.{_options.Region}.myqcloud.com"; - return string.Equals(uri.Host, defaultHost, StringComparison.OrdinalIgnoreCase); - } - - private static (string tmpSecretId, string tmpSecretKey, string token) ExtractCredentials(object? raw) - { - if (raw == null) - { - throw new BusinessException("STS 返回 Credentials 为空"); - } - - var json = JsonConvert.SerializeObject(raw); - var obj = JObject.Parse(json); - var id = obj.Value("TmpSecretId") ?? obj.Value("tmpSecretId"); - var key = obj.Value("TmpSecretKey") ?? obj.Value("tmpSecretKey"); - var token = obj.Value("Token") ?? obj.Value("sessionToken") ?? obj.Value("token"); - if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(token)) - { - throw new BusinessException("STS 凭证字段不完整"); - } - - return (id, key, token); - } - - private static long ToUnixSeconds(object? value) => - value switch - { - null => 0, - long l => l, - int i => i, - string s when long.TryParse(s, out var n) => n, - JValue j when j.Type == JTokenType.Integer => j.Value(), - _ => 0 - }; - - private static string ResolvePublicBaseUrl(TencentCosOptions options) - { - if (!string.IsNullOrWhiteSpace(options.BaseUrl)) - { - return options.BaseUrl.Trim().TrimEnd('/'); - } - - var scheme = options.IsHttps ? "https" : "http"; - return $"{scheme}://{options.Bucket}.cos.{options.Region}.myqcloud.com"; - } - - private static string ExtractAppId(string bucket) - { - var idx = bucket.LastIndexOf('-'); - if (idx <= 0 || idx >= bucket.Length - 1) - { - throw new BusinessException("COS Bucket 格式应为 bucketname-APPID"); - } - - return bucket[(idx + 1)..]; - } - - private static void ValidateOptions(TencentCosOptions options) - { - if (string.IsNullOrWhiteSpace(options.SecretId) - || string.IsNullOrWhiteSpace(options.SecretKey) - || string.IsNullOrWhiteSpace(options.Region) - || string.IsNullOrWhiteSpace(options.Bucket)) - { - throw new BusinessException( - "Oms:FileStorage:Cos 配置不完整,请填写 SecretId / SecretKey / Region / Bucket"); - } - } -} diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 6cc1886..632f352 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Fake.ObjectStorage; using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Domain.CustomersAggregate.Entities; @@ -43,7 +44,7 @@ public partial class AuthService( IWeChatLoginSessionStore weChatLoginSessionStore, IMemoryCache memoryCache, IRbacAuthCache rbacAuthCache, - IFileStorage fileStorage) + IObjectStorage fileStorage) : ApplicationService { private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3); @@ -418,7 +419,7 @@ public partial class AuthService( } output.Position = 0; - var avatarKey = await fileStorage.SaveAsync(output, relativeKey, "image/jpeg", cancellationToken); + var avatarKey = await fileStorage.SaveObjectAsync(output, relativeKey, "image/jpeg", cancellationToken); var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken); user.UpdateAvatar(avatarKey); await userRepository.UpdateAsync(user, cancellationToken: cancellationToken); diff --git a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj index 2648d19..0143f14 100644 --- a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj +++ b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj @@ -15,6 +15,9 @@ + + + diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index bf93dc0..0d0e2a1 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -8,15 +8,20 @@ using Fake.AspNetCore.ExceptionHandling; using Fake.AspNetCore.Newtonsoft; using Fake.AspNetCore.Swagger; using Fake.FeiShu; +using Fake.FileManagement.Application; +using Fake.FileManagement.Infrastructure; +using Fake.ObjectStorage; using Fake.Security.Claims; using Fake.Serilog; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Oms.Application; using Oms.SqlSugarCore; using SimpleAdmin.Api.Auth; +using SimpleAdmin.Api.Storage; using SimpleAdmin.Api.Swagger; using SimpleAdmin.Api.WeChat; using Swashbuckle.AspNetCore.SwaggerGen; @@ -29,6 +34,9 @@ namespace SimpleAdmin.Api; typeof(OmsSqlSugarCoreModule), typeof(FakeRbacApplicationModule), typeof(FakeRbacInfrastructureModule), + typeof(FakeFileManagementApplicationModule), + typeof(FakeFileManagementInfrastructureModule), + typeof(FakeObjectStorageTencentCosModule), typeof(FakeAspNetCoreModule), typeof(FakeSerilogModule) )] @@ -46,7 +54,7 @@ public class SimpleAdminApiModule : FakeModule var services = context.Services; var configuration = context.Services.GetConfiguration(); - // 配置动态 API - 扫描 OMS 与 RBAC 应用服务 + // 配置动态 API - 扫描 OMS / RBAC / FileManagement 应用服务 services.Configure(options => { options.ApplicationServices2Controller(); @@ -63,6 +71,10 @@ public class SimpleAdminApiModule : FakeModule type != typeof(Fake.Rbac.Application.Services.AuthService) && type != typeof(Fake.Rbac.Application.Services.UserService); }); + options.ApplicationServices2Controller(settings => + { + settings.RootPath = "files"; + }); }); services.AddOmsWeChat(configuration); @@ -128,6 +140,8 @@ public class SimpleAdminApiModule : FakeModule }); // 添加授权服务 services.AddAuthorization(); + + services.AddHostedService(); } public override void ConfigureApplication(ApplicationConfigureContext context) @@ -162,6 +176,13 @@ public class SimpleAdminApiModule : FakeModule // 端点映射 app.MapControllers(); + + // FileManagement 与 RBAC 同库并行(EF);应用 Pending migrations 建 stored_file + using (var scope = app.Services.CreateScope()) + { + var fileDb = scope.ServiceProvider.GetRequiredService(); + fileDb.Database.Migrate(); + } app.Services.GetRequiredService().Send("Application started"); } diff --git a/src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs b/src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs new file mode 100644 index 0000000..90a61bc --- /dev/null +++ b/src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs @@ -0,0 +1,36 @@ +using Fake.ObjectStorage; +using Microsoft.Extensions.Options; + +namespace SimpleAdmin.Api.Storage; + +/// +/// 启动时打印实际 IObjectStorage 实现,便于核对 Consul ObjectStorage 配置是否生效。 +/// +internal sealed class ObjectStorageStartupLogger( + IObjectStorage storage, + IOptions options, + ILogger logger) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + var cfg = options.Value; + logger.LogInformation( + "IObjectStorage 实际类型={Implementation},Name={Name},配置 Provider={ConfiguredProvider},SignUrlsByDefault={SignUrlsByDefault},KeyPrefix={KeyPrefix}", + storage.GetType().FullName, + storage.Name, + string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider, + cfg.SignUrlsByDefault, + string.IsNullOrWhiteSpace(cfg.KeyPrefix) ? "(empty)" : cfg.KeyPrefix); + + if (string.Equals(storage.Name, "Local", StringComparison.OrdinalIgnoreCase) + && string.Equals(cfg.Provider, "TencentCos", StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning( + "配置 Provider=TencentCos,但仍在使用 Local。请确认已引用 Fake.ObjectStorage.TencentCos 且 Consul 中 ObjectStorage:Provider 已生效"); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/SimpleAdmin.Api/appsettings.Development.json b/src/SimpleAdmin.Api/appsettings.Development.json index 987adaf..1b28af5 100644 --- a/src/SimpleAdmin.Api/appsettings.Development.json +++ b/src/SimpleAdmin.Api/appsettings.Development.json @@ -59,22 +59,34 @@ "FollowUrl": "" } }, - "Oms": { - "FileStorage": { - "Provider": "Local", - "Cos": { - "SecretId": "", - "SecretKey": "", - "Region": "ap-guangzhou", - "Bucket": "your-bucket-1250000000", - "BaseUrl": "", - "KeyPrefix": "", - "DurationSecond": 3600, - "UploadDurationSecond": 600, - "SignUrls": true, - "IsHttps": true - } + "ObjectStorage": { + "Provider": "Local", + "KeyPrefix": "", + "SignUrlsByDefault": true, + "DefaultSignedUrlExpiresSeconds": 3600, + "DefaultUploadExpiresSeconds": 600, + "Local": { + "BasePath": "wwwroot", + "BaseUrl": "" }, + "TencentCos": { + "SecretId": "", + "SecretKey": "", + "Region": "ap-guangzhou", + "Bucket": "your-bucket-1250000000", + "BaseUrl": "", + "SignUrls": true, + "UseHttps": true, + "DurationSecond": 600 + } + }, + "FileManagement": { + "SignUrlsByDefault": true, + "DefaultSignedUrlExpiresSeconds": 3600, + "DefaultMaxSizeBytes": 20971520, + "DefaultStorageSource": "default" + }, + "Oms": { "TrackService": { "Enabled": true, "BaseUrl": "http://api.nanafa.com/track", -- 2.45.2 From c9084ee5cab625aed21f1c7242e00946f34c9f2f Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 23 Sep 2026 11:37:27 +0800 Subject: [PATCH 02/41] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=20master=20?= =?UTF-8?q?=E5=90=8E=E6=94=B9=E7=94=A8=20IObjectStorage=EF=BC=8C=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20fake=20oss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Services/Orders/Batch/OrderBatchActionExecutor.cs | 3 ++- .../Services/Orders/Batch/OrderBatchJobCoordinator.cs | 3 ++- .../Services/Orders/OrderService.OrderEdit.cs | 3 ++- .../Services/Orders/OrderService.ParcelBilling.cs | 3 ++- .../Shared/OrderDownstreamCreateOneBodyBuilder.cs | 5 +++-- src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs | 4 ++-- 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs index a88f537..913cd7a 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs @@ -1,5 +1,6 @@ using Fake.Application; using Fake.DependencyInjection; +using Fake.ObjectStorage; using Newtonsoft.Json; using Oms.Application.Dtos.Orders; using Oms.Application.Integrations.Itfx; @@ -19,7 +20,7 @@ public sealed class OrderBatchActionExecutor( IOrderRepository orderRepository, ICountryRepository countryRepository, IServiceChannelRepository serviceChannelRepository, - IFileStorage fileStorage, + IObjectStorage fileStorage, ISmallBagTrackOrderClient smallBagTrackOrderClient, ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder, OrderDownstreamCredentialResolver downstreamCredentialResolver, diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs index fed05fe..b73fa91 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs @@ -7,6 +7,7 @@ using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Messaging; +using Fake.ObjectStorage; using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Domain.ChannelsAggregate.Repositories; @@ -26,7 +27,7 @@ public sealed class OrderBatchJobCoordinator( IOrderRepository orderRepository, ICountryRepository countryRepository, IServiceChannelRepository serviceChannelRepository, - IFileStorage fileStorage, + IObjectStorage fileStorage, IOrderBatchJobPublisher publisher, OrderBatchJobProcessor batchJobProcessor, IOptions batchOptions, diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 57aec08..616c9f0 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -5,6 +5,7 @@ using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; +using Oms.Application.Storage; using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; @@ -1129,7 +1130,7 @@ public partial class OrderService ImHsCode = x.ImHsCode, ExHsCode = x.ExHsCode ?? x.ImHsCode, DeclareElement = x.DeclareElement, - SkuImage = FileStorage.ToStorageKey(x.SkuImage), + SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage), Category = x.Category, Brand = x.Brand, BrandType = x.BrandType, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 0ef8833..1217f92 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -10,6 +10,7 @@ using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Labeling; using Oms.Application.Messaging; using Oms.Application.Shared; +using Oms.Application.Storage; using Oms.Application.Services.Orders.Batch; using Oms.Application.Services.Orders.Issues; using Oms.Domain.ChannelsAggregate.Consts; @@ -2013,7 +2014,7 @@ public partial class OrderService ImHsCode = x.ImHsCode, ExHsCode = x.ExHsCode, DeclareElement = x.DeclareElement, - SkuImage = FileStorage.ToStorageKey(x.SkuImage), + SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage), Category = x.Category, Brand = x.Brand, BrandType = x.BrandType, diff --git a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs index 22bdf36..9908371 100644 --- a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs +++ b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs @@ -1,3 +1,4 @@ +using Fake.ObjectStorage; using Oms.Application.Dtos.Orders; using Oms.Application.Storage; using Oms.Domain.ChannelsAggregate.Repositories; @@ -20,7 +21,7 @@ public static class OrderDownstreamCreateOneBodyBuilder IReadOnlyList weights, ICountryRepository countryRepository, IServiceChannelRepository serviceChannelRepository, - IFileStorage? fileStorage = null, + IObjectStorage? fileStorage = null, CancellationToken cancellationToken = default) { var countryIds = new List(); @@ -185,7 +186,7 @@ public static class OrderDownstreamCreateOneBodyBuilder }; } - private static OrderParcelItemInput MapItem(OrderDeclaration x, IFileStorage? fileStorage) => + private static OrderParcelItemInput MapItem(OrderDeclaration x, IObjectStorage? fileStorage) => new() { CnName = x.CnName, diff --git a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs index 444ade9..37ecb2f 100644 --- a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs +++ b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs @@ -1,12 +1,12 @@ using FluentAssertions; using Fake.Application; +using Fake.ObjectStorage; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Oms.Application.Dtos.Orders; using Oms.Application.Messaging; using Oms.Application.Services.Orders.Batch; -using Oms.Application.Storage; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; @@ -21,7 +21,7 @@ public class OrderBatchJobCoordinatorGovernanceTests private readonly Mock _orderRepository = new(MockBehavior.Loose); private readonly Mock _countryRepository = new(MockBehavior.Loose); private readonly Mock _serviceChannelRepository = new(MockBehavior.Loose); - private readonly Mock _fileStorage = new(MockBehavior.Loose); + private readonly Mock _fileStorage = new(MockBehavior.Loose); private readonly Mock _publisher = new(MockBehavior.Strict); private readonly OrderBatchJobCoordinator _coordinator; -- 2.45.2 From 26c840cdb195297c1c3e01e1fe99157660661094 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 24 Sep 2026 09:51:02 +0800 Subject: [PATCH 03/41] =?UTF-8?q?fix:=20=E5=8A=A0=E5=9B=BA=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=AD=98=E5=82=A8=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=E5=88=9B=E5=8D=95=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=8F=BD=E6=94=B6=E5=A4=A7=E5=8C=85=E8=BD=A8=E8=BF=B9=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免 /app/wwwroot 不可写时预报成功被回滚;同步推送 test 部署出口资质等已合入能力。 Co-authored-by: Cursor --- .../ISmallBagTrackOrderClient.cs | 30 +- .../SmallBagTrack/PickupBigBagTrackSyncer.cs | 205 +++++++++++ .../SmallBagTrackLifecycleEvents.cs | 8 +- .../SmallBagTrackLinkOrderApiRequest.cs | 24 ++ .../SmallBagTrack/SmallBagTrackOrderClient.cs | 324 +++++++++++++++--- .../SmallBagTrackServiceExtensions.cs | 1 + .../SmallBagTrackUpsertBigBagApiRequest.cs | 22 ++ .../SmallBagTrackUpsertBigBagRequest.cs | 21 ++ .../SmallBagTrackUpsertTrackApiRequest.cs | 5 +- .../Services/Orders/OrderService.Helpers.cs | 13 +- .../Orders/OrderService.ParcelBilling.cs | 41 ++- .../Services/Orders/PickupOrderService.cs | 9 + .../Storage/OmsObjectStorageExtensions.cs | 18 +- 13 files changed, 661 insertions(+), 60 deletions(-) create mode 100644 src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs create mode 100644 src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs create mode 100644 src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs create mode 100644 src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs diff --git a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs index 9714178..c3e8190 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs @@ -5,7 +5,7 @@ public interface ISmallBagTrackOrderClient Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default); /// - /// 追加一条生命周期轨迹节点(POST ProviderService/Track/Upsert)。 + /// 追加一条生命周期轨迹节点(POST ProviderService/Track/Upsert),默认小包 orderType=1。 /// Task AppendLifecycleTrackAsync( string orderNo, @@ -13,8 +13,36 @@ public interface ISmallBagTrackOrderClient string? authorization = null, CancellationToken cancellationToken = default); + /// + /// 追加生命周期轨迹节点,返回调用结果(用于按序编排)。 + /// + Task AppendLifecycleTrackResultAsync( + string orderNo, + string trackCode, + int orderType, + string? authorization = null, + CancellationToken cancellationToken = default); + /// /// 同步调用并返回完整请求/响应(仅调试)。 /// Task UpsertDebugAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default); + + /// + /// 创建/更新大包轨迹订单(POST ProviderService/Order/UpsertBigBag)。 + /// + Task UpsertBigBagAsync( + SmallBagTrackUpsertBigBagRequest request, + CancellationToken cancellationToken = default); + + /// + /// 关联大包与小包(POST ProviderService/Order/Link)。 + /// + Task LinkOrdersAsync( + string parentOrderNo, + int parentOrderType, + IReadOnlyList linkOrderNos, + int linkOrderType, + string? authorization = null, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs new file mode 100644 index 0000000..e3bca81 --- /dev/null +++ b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs @@ -0,0 +1,205 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Integrations.SmallBagTrack; + +/// +/// 揽收单预报下游成功后:按序 UpsertBigBag → Link → Track/Upsert(A0201)。 +/// +public interface IPickupBigBagTrackSyncer +{ + Task SyncAfterDownstreamForwardAsync(PickupOrder pickup, CancellationToken cancellationToken = default); +} + +internal sealed class PickupBigBagTrackSyncer( + ISmallBagTrackOrderClient trackClient, + ICustomerRepository customerRepository, + IPickupOrderLineRepository pickupOrderLineRepository, + ISalesProductRepository salesProductRepository, + IServiceChannelRepository serviceChannelRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) : IPickupBigBagTrackSyncer +{ + public async Task SyncAfterDownstreamForwardAsync( + PickupOrder pickup, + CancellationToken cancellationToken = default) + { + if (pickup == null || string.IsNullOrWhiteSpace(pickup.PickupNo)) + { + logger.LogWarning("Skip big-bag track sync: pickup or PickupNo empty"); + return; + } + + var bagNo = pickup.PickupNo.Trim(); + var authorization = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(authorization)) + { + authorization = null; + } + + try + { + var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == pickup.CustomerId, cancellationToken); + var clientCode = customer?.CustomerCode?.Trim() ?? string.Empty; + var (channel, product) = await ResolveChannelProductAsync(pickup.CarrierService, cancellationToken); + var country = ResolveCountry(pickup); + + // 1) UpsertBigBag + var upsertResult = await trackClient.UpsertBigBagAsync( + new SmallBagTrackUpsertBigBagRequest + { + Client = clientCode, + BagNo = bagNo, + OtherNo = pickup.CarrierWaybillNo, + Channel = channel, + Product = product, + Country = country, + Authorization = authorization + }, + cancellationToken); + + if (upsertResult.Skipped) + { + logger.LogDebug( + "UpsertBigBag skipped for pickup {PickupNo}: {Reason}", + bagNo, + upsertResult.SkipReason); + return; + } + + if (!upsertResult.Success) + { + logger.LogWarning( + "Stop big-bag track sync after UpsertBigBag failure for pickup {PickupNo}", + bagNo); + return; + } + + // 2) Link(无明细单号则跳过,继续写轨迹) + var lines = await pickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id, cancellationToken); + var serviceNos = lines + .Select(x => x.ServiceNo) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (serviceNos.Count > 0) + { + var linkResult = await trackClient.LinkOrdersAsync( + bagNo, + SmallBagTrackLifecycleEvents.BigBagOrderType, + serviceNos, + SmallBagTrackLifecycleEvents.OrderType, + authorization, + cancellationToken); + + if (linkResult.Skipped) + { + logger.LogDebug( + "Link skipped for pickup {PickupNo}: {Reason}", + bagNo, + linkResult.SkipReason); + } + else if (!linkResult.Success) + { + logger.LogWarning( + "Stop big-bag track sync after Link failure for pickup {PickupNo}", + bagNo); + return; + } + } + else + { + logger.LogDebug("Skip Link for pickup {PickupNo}: no bound ServiceNo", bagNo); + } + + // 3) Track Upsert A0201 + var trackResult = await trackClient.AppendLifecycleTrackResultAsync( + bagNo, + SmallBagTrackLifecycleEvents.PickupCreateTrackCode, + SmallBagTrackLifecycleEvents.BigBagOrderType, + authorization, + cancellationToken); + + if (trackResult.Skipped) + { + logger.LogDebug( + "Track A0201 skipped for pickup {PickupNo}: {Reason}", + bagNo, + trackResult.SkipReason); + return; + } + + if (!trackResult.Success) + { + logger.LogWarning( + "Track A0201 failed for pickup {PickupNo}: {StatusCode} {Body}", + bagNo, + trackResult.StatusCode, + trackResult.ResponseBody); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Big-bag track sync failed for pickup {PickupNo}", bagNo); + } + } + + private async Task<(string? Channel, string? Product)> ResolveChannelProductAsync( + string? carrierService, + CancellationToken cancellationToken) + { + var salesProductCode = carrierService?.Trim(); + if (string.IsNullOrWhiteSpace(salesProductCode)) + { + return (null, null); + } + + var product = await salesProductRepository.GetByProductCodeAsync(salesProductCode, cancellationToken); + if (product == null) + { + return (null, salesProductCode); + } + + var providers = await salesProductRepository.GetAllocationProvidersAsync(product.Id, cancellationToken); + var channelId = providers + .Where(x => x.ServiceChannelId is > 0) + .OrderBy(x => x.SerialNumber) + .Select(x => x.ServiceChannelId!.Value) + .FirstOrDefault(); + if (channelId <= 0) + { + return (null, product.ProductCode); + } + + var channel = await serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId, cancellationToken); + var channelCode = channel?.ChannelCode?.Trim(); + return ( + string.IsNullOrWhiteSpace(channelCode) ? null : channelCode, + product.ProductCode); + } + + private static string ResolveCountry(PickupOrder pickup) + { + var isReverse = string.Equals( + PickupDirectionCodes.NormalizeOrDefault(pickup.Direction), + PickupDirectionCodes.Reverse, + StringComparison.OrdinalIgnoreCase); + + if (isReverse) + { + return string.IsNullOrWhiteSpace(pickup.SenderCountryCode) + ? "CN" + : pickup.SenderCountryCode.Trim().ToUpperInvariant(); + } + + // 正向:目的仓,仓库无国别字段,默认 CN + return "CN"; + } +} diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs index fceed43..15d6ca8 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLifecycleEvents.cs @@ -1,12 +1,15 @@ namespace Oms.Application.Integrations.SmallBagTrack; /// -/// 小包订单生命周期默认轨迹节点(POST ProviderService/Track/Upsert)。 +/// 订单生命周期默认轨迹节点(POST ProviderService/Track/Upsert)。 /// internal static class SmallBagTrackLifecycleEvents { public const string CreateTrackCode = "A0101"; + /// 揽收大包创单轨迹。 + public const string PickupCreateTrackCode = "A0201"; + public const string InboundTrackCode = "A0301"; public const string OutboundTrackCode = "A0701"; @@ -18,6 +21,9 @@ internal static class SmallBagTrackLifecycleEvents /// 轨迹订单类型:小包。 public const int OrderType = 1; + /// 轨迹订单类型:大包。 + public const int BigBagOrderType = 2; + /// 数据来源:内部系统。 public const int Source = 2; } diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs new file mode 100644 index 0000000..9a32edd --- /dev/null +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackLinkOrderApiRequest.cs @@ -0,0 +1,24 @@ +namespace Oms.Application.Integrations.SmallBagTrack; + +/// +/// POST /ProviderService/Order/Link 业务请求体。 +/// +internal sealed class SmallBagTrackLinkOrderApiRequest +{ + public string AppKey { get; set; } = string.Empty; + + public bool IsLink { get; set; } = true; + + public int OrderType { get; set; } + + public string OrderNo { get; set; } = string.Empty; + + public string LinkAppKey { get; set; } = string.Empty; + + public int LinkOrderType { get; set; } + + public List LinkOrderNos { get; set; } = []; + + public static string BuildEndpointUrl(string baseUrl) => + $"{baseUrl.Trim().TrimEnd('/')}/ProviderService/Order/Link"; +} diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs index b97d847..b6e5077 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs @@ -51,7 +51,114 @@ internal sealed class SmallBagTrackOrderClient( string trackCode, string? authorization = null, CancellationToken cancellationToken = default) => - AppendLifecycleTrackCoreAsync(orderNo, trackCode, authorization, cancellationToken); + AppendLifecycleTrackResultAsync( + orderNo, + trackCode, + SmallBagTrackLifecycleEvents.OrderType, + authorization, + cancellationToken); + + public async Task AppendLifecycleTrackResultAsync( + string orderNo, + string trackCode, + int orderType, + string? authorization = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(orderNo)) + { + logger.LogWarning("Skip lifecycle track append: orderNo empty"); + return Skipped("orderNo empty"); + } + + if (string.IsNullOrWhiteSpace(trackCode)) + { + logger.LogWarning("Skip lifecycle track append for order {OrderNo}: trackCode empty", orderNo); + return Skipped("trackCode empty"); + } + + var settings = await configProvider.GetAsync(cancellationToken); + if (!settings.Enabled) + { + return Skipped("TrackService disabled"); + } + + if (string.IsNullOrWhiteSpace(settings.BaseUrl)) + { + return Skipped("BaseUrl empty"); + } + + if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret)) + { + return Skipped("AppKey or AppSecret not configured"); + } + + var appKey = settings.AppKey.Trim(); + var auth = ResolveAuthorization(authorization); + var trackTime = DateTime.Now; + var body = SmallBagTrackUpsertTrackApiRequest.CreateLifecycleEvent( + appKey, + orderNo.Trim(), + trackCode.Trim(), + trackTime, + orderType); + var url = SmallBagTrackUpsertTrackApiRequest.BuildEndpointUrl(settings.BaseUrl); + var requestJson = JsonSerializer.Serialize(body, JsonOptions); + + try + { + var response = await SendSignedPostAsync( + settings, + appKey, + url, + body, + auth, + cancellationToken); + + var result = new SmallBagTrackUpsertResult + { + Skipped = false, + Success = response.Response.IsSuccessStatusCode, + Url = url, + RequestJson = requestJson, + StatusCode = (int)response.Response.StatusCode, + ResponseBody = response.ResponseBody + }; + + if (result.Success) + { + logger.LogInformation( + "Lifecycle track {TrackCode} appended for order {OrderNo} (orderType={OrderType}) at {TrackPlace}", + trackCode, + orderNo, + orderType, + SmallBagTrackLifecycleEvents.TrackPlace); + } + else + { + logger.LogWarning( + "Lifecycle track {TrackCode} append failed for order {OrderNo}: {StatusCode} {Body}", + trackCode, + orderNo, + result.StatusCode, + result.ResponseBody); + } + + return result; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Lifecycle track {TrackCode} append failed for order {OrderNo}", trackCode, orderNo); + return new SmallBagTrackUpsertResult + { + Skipped = false, + Success = false, + Url = url, + RequestJson = requestJson, + ResponseBody = ex.Message + }; + } + } public async Task UpsertDebugAsync( SmallBagTrackUpsertRequest request, @@ -76,7 +183,7 @@ internal sealed class SmallBagTrackOrderClient( var appKey = settings.AppKey.Trim(); var body = SmallBagTrackUpsertApiRequest.FromBusinessRequest(request); var url = SmallBagTrackUpsertApiRequest.BuildEndpointUrl(settings.BaseUrl); - var authorization = ResolveAuthorization(request); + var authorization = ResolveAuthorization(request.Authorization); var requestJson = JsonSerializer.Serialize(body, JsonOptions); var response = await SendSignedPostAsync( @@ -120,41 +227,44 @@ internal sealed class SmallBagTrackOrderClient( return result; } - private async Task AppendLifecycleTrackCoreAsync( - string orderNo, - string trackCode, - string? authorization, - CancellationToken cancellationToken) + public async Task UpsertBigBagAsync( + SmallBagTrackUpsertBigBagRequest request, + CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(orderNo)) + if (string.IsNullOrWhiteSpace(request.BagNo)) { - logger.LogWarning("Skip lifecycle track append: orderNo empty"); - return; - } - - if (string.IsNullOrWhiteSpace(trackCode)) - { - logger.LogWarning("Skip lifecycle track append for order {OrderNo}: trackCode empty", orderNo); - return; + return Skipped("bagNo empty"); } var settings = await configProvider.GetAsync(cancellationToken); - if (!settings.Enabled - || string.IsNullOrWhiteSpace(settings.BaseUrl) - || string.IsNullOrWhiteSpace(settings.AppKey) - || string.IsNullOrWhiteSpace(settings.AppSecret)) + if (!settings.Enabled) { - return; + return Skipped("TrackService disabled"); + } + + if (string.IsNullOrWhiteSpace(settings.BaseUrl)) + { + return Skipped("BaseUrl empty"); + } + + if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret)) + { + return Skipped("AppKey or AppSecret not configured"); } var appKey = settings.AppKey.Trim(); - var trackTime = DateTime.Now; - var body = SmallBagTrackUpsertTrackApiRequest.CreateLifecycleEvent( - appKey, - orderNo.Trim(), - trackCode.Trim(), - trackTime); - var url = SmallBagTrackUpsertTrackApiRequest.BuildEndpointUrl(settings.BaseUrl); + var body = new SmallBagTrackUpsertBigBagApiRequest + { + Client = request.Client?.Trim() ?? string.Empty, + BagNo = request.BagNo.Trim(), + OtherNo = request.OtherNo?.Trim() ?? string.Empty, + Channel = request.Channel?.Trim() ?? string.Empty, + Product = request.Product?.Trim() ?? string.Empty, + Country = request.Country?.Trim() ?? string.Empty + }; + var url = SmallBagTrackUpsertBigBagApiRequest.BuildEndpointUrl(settings.BaseUrl); + var authorization = ResolveAuthorization(request.Authorization); + var requestJson = JsonSerializer.Serialize(body, JsonOptions); try { @@ -166,26 +276,150 @@ internal sealed class SmallBagTrackOrderClient( authorization, cancellationToken); - if (response.Response.IsSuccessStatusCode) + var result = new SmallBagTrackUpsertResult { - logger.LogInformation( - "Lifecycle track {TrackCode} appended for order {OrderNo} at {TrackPlace}", - trackCode, - orderNo, - SmallBagTrackLifecycleEvents.TrackPlace); - return; + Skipped = false, + Success = response.Response.IsSuccessStatusCode, + Url = url, + RequestJson = requestJson, + StatusCode = (int)response.Response.StatusCode, + ResponseBody = response.ResponseBody + }; + + if (result.Success) + { + logger.LogInformation("UpsertBigBag succeeded for bag {BagNo}", body.BagNo); + } + else + { + logger.LogWarning( + "UpsertBigBag failed for bag {BagNo}: {StatusCode} {Body}", + body.BagNo, + result.StatusCode, + result.ResponseBody); } - logger.LogWarning( - "Lifecycle track {TrackCode} append failed for order {OrderNo}: {StatusCode} {Body}", - trackCode, - orderNo, - (int)response.Response.StatusCode, - response.ResponseBody); + return result; } catch (Exception ex) { - logger.LogWarning(ex, "Lifecycle track {TrackCode} append failed for order {OrderNo}", trackCode, orderNo); + logger.LogWarning(ex, "UpsertBigBag failed for bag {BagNo}", request.BagNo); + return new SmallBagTrackUpsertResult + { + Skipped = false, + Success = false, + Url = url, + RequestJson = requestJson, + ResponseBody = ex.Message + }; + } + } + + public async Task LinkOrdersAsync( + string parentOrderNo, + int parentOrderType, + IReadOnlyList linkOrderNos, + int linkOrderType, + string? authorization = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(parentOrderNo)) + { + return Skipped("parentOrderNo empty"); + } + + var nos = (linkOrderNos ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Where(x => !string.Equals(x, parentOrderNo.Trim(), StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (nos.Count == 0) + { + return Skipped("linkOrderNos empty"); + } + + var settings = await configProvider.GetAsync(cancellationToken); + if (!settings.Enabled) + { + return Skipped("TrackService disabled"); + } + + if (string.IsNullOrWhiteSpace(settings.BaseUrl)) + { + return Skipped("BaseUrl empty"); + } + + if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret)) + { + return Skipped("AppKey or AppSecret not configured"); + } + + var appKey = settings.AppKey.Trim(); + var body = new SmallBagTrackLinkOrderApiRequest + { + AppKey = appKey, + IsLink = true, + OrderType = parentOrderType, + OrderNo = parentOrderNo.Trim(), + LinkAppKey = appKey, + LinkOrderType = linkOrderType, + LinkOrderNos = nos + }; + var url = SmallBagTrackLinkOrderApiRequest.BuildEndpointUrl(settings.BaseUrl); + var auth = ResolveAuthorization(authorization); + var requestJson = JsonSerializer.Serialize(body, JsonOptions); + + try + { + var response = await SendSignedPostAsync( + settings, + appKey, + url, + body, + auth, + cancellationToken); + + var result = new SmallBagTrackUpsertResult + { + Skipped = false, + Success = response.Response.IsSuccessStatusCode, + Url = url, + RequestJson = requestJson, + StatusCode = (int)response.Response.StatusCode, + ResponseBody = response.ResponseBody + }; + + if (result.Success) + { + logger.LogInformation( + "LinkOrders succeeded for parent {OrderNo} -> {Count} children", + body.OrderNo, + nos.Count); + } + else + { + logger.LogWarning( + "LinkOrders failed for parent {OrderNo}: {StatusCode} {Body}", + body.OrderNo, + result.StatusCode, + result.ResponseBody); + } + + return result; + } + catch (Exception ex) + { + logger.LogWarning(ex, "LinkOrders failed for parent {OrderNo}", parentOrderNo); + return new SmallBagTrackUpsertResult + { + Skipped = false, + Success = false, + Url = url, + RequestJson = requestJson, + ResponseBody = ex.Message + }; } } @@ -215,11 +449,11 @@ internal sealed class SmallBagTrackOrderClient( return new SignedTrackHttpResponse(response, responseBody, timestamp, sign); } - private string? ResolveAuthorization(SmallBagTrackUpsertRequest request) + private string? ResolveAuthorization(string? authorization) { - if (!string.IsNullOrWhiteSpace(request.Authorization)) + if (!string.IsNullOrWhiteSpace(authorization)) { - return request.Authorization.Trim(); + return authorization.Trim(); } return httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs index d8d0bb3..2427d8e 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs @@ -12,6 +12,7 @@ public static class SmallBagTrackServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); return services; } diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs new file mode 100644 index 0000000..72e1c00 --- /dev/null +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs @@ -0,0 +1,22 @@ +namespace Oms.Application.Integrations.SmallBagTrack; + +/// +/// POST /ProviderService/Order/UpsertBigBag 业务请求体。 +/// +internal sealed class SmallBagTrackUpsertBigBagApiRequest +{ + public string Client { get; set; } = string.Empty; + + public string BagNo { get; set; } = string.Empty; + + public string OtherNo { get; set; } = string.Empty; + + public string Channel { get; set; } = string.Empty; + + public string Product { get; set; } = string.Empty; + + public string Country { get; set; } = string.Empty; + + public static string BuildEndpointUrl(string baseUrl) => + $"{baseUrl.Trim().TrimEnd('/')}/ProviderService/Order/UpsertBigBag"; +} diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs new file mode 100644 index 0000000..3635ec7 --- /dev/null +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs @@ -0,0 +1,21 @@ +namespace Oms.Application.Integrations.SmallBagTrack; + +/// +/// 大包轨迹订单业务请求(UpsertBigBag)。 +/// +public class SmallBagTrackUpsertBigBagRequest +{ + public string Client { get; set; } = string.Empty; + + public string BagNo { get; set; } = string.Empty; + + public string? OtherNo { get; set; } + + public string? Channel { get; set; } + + public string? Product { get; set; } + + public string? Country { get; set; } + + public string? Authorization { get; set; } +} diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs index 24f2c05..261462f 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertTrackApiRequest.cs @@ -24,11 +24,12 @@ internal sealed class SmallBagTrackUpsertTrackApiRequest string appKey, string orderNo, string trackCode, - DateTime trackTime) => + DateTime trackTime, + int orderType = SmallBagTrackLifecycleEvents.OrderType) => new() { AppKey = appKey, - OrderType = SmallBagTrackLifecycleEvents.OrderType, + OrderType = orderType, OrderNo = orderNo, IsAdd = true, TrackCode = trackCode, diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 79d6295..7d1d00b 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -640,7 +640,18 @@ public partial class OrderService ? Path.Combine(WebHostEnvironment.ContentRootPath, "wwwroot") : WebHostEnvironment.WebRootPath; // 只确保 uploads 子目录可写,避免在只读 ContentRoot 上强建 wwwroot 根目录。 - Directory.CreateDirectory(Path.Combine(webRoot, "uploads")); + try + { + Directory.CreateDirectory(Path.Combine(webRoot, "uploads")); + } + catch (UnauthorizedAccessException) + { + // 容器非 root + /app/wwwroot 未预建/权限错误时,导出清理等旁路不应炸主流程 + } + catch (IOException) + { + } + return webRoot; } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 1217f92..fb58dee 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2588,26 +2588,49 @@ public partial class OrderService private async Task BuildParcelCreateOutputAsync(Order order) { - var mapped = (await MapOrderOutputsAsync([order])).First(); - var systemLabelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id); - var labelUrl = systemLabelUrl ?? string.Empty; - if (string.IsNullOrWhiteSpace(labelUrl) && order.OrderStatus == OrderStatusCodes.Predeclared) + // 预报已成功时,映射/面单/本地存储失败不得回滚订单(常见:容器 /app/wwwroot 不可写)。 + var logger = LazyServiceProvider.GetRequiredService() + .CreateLogger("Oms.OrderParcelCreate"); + var labelUrl = string.Empty; + try { - // 回落 HTML 面单仅用于返回 LabelUrl;写盘失败不得回滚已预报成功的订单。 + var mapped = (await MapOrderOutputsAsync([order])).First(); try { - labelUrl = await CreateOrderPrintFileAsync(mapped); + labelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id) ?? string.Empty; } catch (Exception ex) { - var logger = LazyServiceProvider.GetRequiredService() - .CreateLogger("Oms.OrderParcelCreate"); logger.LogWarning( ex, - "预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}", + "预报成功但读取系统面单 URL 失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo); } + + if (string.IsNullOrWhiteSpace(labelUrl) && order.OrderStatus == OrderStatusCodes.Predeclared) + { + try + { + labelUrl = await CreateOrderPrintFileAsync(mapped); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}", + order.Id, + order.ServiceNo); + } + } + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "预报成功但组装创单出参失败 OrderId={OrderId} ServiceNo={ServiceNo}", + order.Id, + order.ServiceNo); } return new OrderParcelCreateOutput diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs index fc10454..bd33958 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; using Oms.Application.Integrations.Itfx; +using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Repositories; @@ -65,6 +66,9 @@ public class PickupOrderService : ApplicationService private IItfxMetadataClient ItfxMetadataClient => LazyServiceProvider.GetRequiredService(); + private IPickupBigBagTrackSyncer PickupBigBagTrackSyncer => + LazyServiceProvider.GetRequiredService(); + /// /// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。 /// @@ -983,6 +987,11 @@ public class PickupOrderService : ApplicationService } } + if (forward.Success) + { + await PickupBigBagTrackSyncer.SyncAfterDownstreamForwardAsync(pickup); + } + return new OrderDownstreamForwardResult { Success = forward.Success, diff --git a/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs b/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs index ab34314..28910bc 100644 --- a/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs +++ b/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs @@ -33,6 +33,7 @@ public static class OmsObjectStorageExtensions /// /// 临时访问地址。空值返回空串;外链原样返回;逻辑 key 走 。 + /// Local 存储目录不可写/不可读时降级返回 key,避免拖垮下单等主流程。 /// public static async Task GetPublicUrlAsync( this IObjectStorage storage, @@ -51,7 +52,22 @@ public static class OmsObjectStorageExtensions } var key = FileStoragePath.NormalizeKey(raw); - return await storage.GetUrlAsync(key, cancellationToken: cancellationToken); + try + { + return await storage.GetUrlAsync(key, cancellationToken: cancellationToken); + } + catch (UnauthorizedAccessException) + { + return key; + } + catch (DirectoryNotFoundException) + { + return key; + } + catch (IOException) + { + return key; + } } /// -- 2.45.2 From dd4644e914ed7cc144861e4f10a45eac172cd0c8 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 24 Sep 2026 15:00:36 +0800 Subject: [PATCH 04/41] =?UTF-8?q?feat:=20=E6=8F=BD=E6=94=B6=E5=A4=A7?= =?UTF-8?q?=E5=8C=85=E8=BD=A8=E8=BF=B9=E6=89=8B=E5=8A=A8=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E9=87=8D=E7=BD=AE=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 track-upsert-big-bag 返回三步联调结果;补齐 reset-password API。 Co-authored-by: Cursor --- .../Dtos/Rbac/OmsUserProfileDtos.cs | 11 +++ .../SmallBagTrack/PickupBigBagTrackSyncer.cs | 80 +++++++++++++++++-- .../Services/Orders/PickupOrderService.cs | 10 +++ .../Services/Rbac/OmsUserService.cs | 30 +++++++ 4 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs index 903a8f2..415831c 100644 --- a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs +++ b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs @@ -54,3 +54,14 @@ public class OmsUserDetailDto : UserDto public string? WecomKfUrl { get; set; } } + +/// +/// 管理员重置用户密码入参。 +/// +public class ResetUserPasswordInput +{ + /// + /// 新密码。 + /// + public string NewPassword { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs index e3bca81..fc63b2c 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs @@ -9,11 +9,36 @@ using Oms.Domain.OrdersAggregate.Repositories; namespace Oms.Application.Integrations.SmallBagTrack; /// -/// 揽收单预报下游成功后:按序 UpsertBigBag → Link → Track/Upsert(A0201)。 +/// 揽收大包轨迹同步结果(UpsertBigBag → Link → Track A0201)。 +/// +public class PickupBigBagTrackSyncResult +{ + public string PickupNo { get; set; } = string.Empty; + + public bool Success { get; set; } + + public string? FailStep { get; set; } + + public string? Message { get; set; } + + public SmallBagTrackUpsertResult? UpsertBigBag { get; set; } + + public SmallBagTrackUpsertResult? Link { get; set; } + + public SmallBagTrackUpsertResult? TrackUpsert { get; set; } +} + +/// +/// 揽收单预报下游成功后 / 手动:按序 UpsertBigBag → Link → Track/Upsert(A0201)。 /// public interface IPickupBigBagTrackSyncer { Task SyncAfterDownstreamForwardAsync(PickupOrder pickup, CancellationToken cancellationToken = default); + + /// + /// 同步执行三步并返回逐步请求/响应(手动「创建轨迹订单」联调)。 + /// + Task SyncDebugAsync(PickupOrder pickup, CancellationToken cancellationToken = default); } internal sealed class PickupBigBagTrackSyncer( @@ -29,13 +54,29 @@ internal sealed class PickupBigBagTrackSyncer( PickupOrder pickup, CancellationToken cancellationToken = default) { + await SyncDebugAsync(pickup, cancellationToken); + } + + public async Task SyncDebugAsync( + PickupOrder pickup, + CancellationToken cancellationToken = default) + { + var result = new PickupBigBagTrackSyncResult + { + PickupNo = pickup?.PickupNo?.Trim() ?? string.Empty + }; + if (pickup == null || string.IsNullOrWhiteSpace(pickup.PickupNo)) { + result.Success = false; + result.FailStep = "validate"; + result.Message = "pickup or PickupNo empty"; logger.LogWarning("Skip big-bag track sync: pickup or PickupNo empty"); - return; + return result; } var bagNo = pickup.PickupNo.Trim(); + result.PickupNo = bagNo; var authorization = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); if (string.IsNullOrWhiteSpace(authorization)) { @@ -62,22 +103,29 @@ internal sealed class PickupBigBagTrackSyncer( Authorization = authorization }, cancellationToken); + result.UpsertBigBag = upsertResult; if (upsertResult.Skipped) { + result.Success = false; + result.FailStep = "UpsertBigBag"; + result.Message = upsertResult.SkipReason ?? "skipped"; logger.LogDebug( "UpsertBigBag skipped for pickup {PickupNo}: {Reason}", bagNo, upsertResult.SkipReason); - return; + return result; } if (!upsertResult.Success) { + result.Success = false; + result.FailStep = "UpsertBigBag"; + result.Message = $"HTTP {upsertResult.StatusCode}"; logger.LogWarning( "Stop big-bag track sync after UpsertBigBag failure for pickup {PickupNo}", bagNo); - return; + return result; } // 2) Link(无明细单号则跳过,继续写轨迹) @@ -98,6 +146,7 @@ internal sealed class PickupBigBagTrackSyncer( SmallBagTrackLifecycleEvents.OrderType, authorization, cancellationToken); + result.Link = linkResult; if (linkResult.Skipped) { @@ -108,10 +157,13 @@ internal sealed class PickupBigBagTrackSyncer( } else if (!linkResult.Success) { + result.Success = false; + result.FailStep = "Link"; + result.Message = $"HTTP {linkResult.StatusCode}"; logger.LogWarning( "Stop big-bag track sync after Link failure for pickup {PickupNo}", bagNo); - return; + return result; } } else @@ -126,28 +178,44 @@ internal sealed class PickupBigBagTrackSyncer( SmallBagTrackLifecycleEvents.BigBagOrderType, authorization, cancellationToken); + result.TrackUpsert = trackResult; if (trackResult.Skipped) { + result.Success = false; + result.FailStep = "TrackUpsert"; + result.Message = trackResult.SkipReason ?? "skipped"; logger.LogDebug( "Track A0201 skipped for pickup {PickupNo}: {Reason}", bagNo, trackResult.SkipReason); - return; + return result; } if (!trackResult.Success) { + result.Success = false; + result.FailStep = "TrackUpsert"; + result.Message = $"HTTP {trackResult.StatusCode}"; logger.LogWarning( "Track A0201 failed for pickup {PickupNo}: {StatusCode} {Body}", bagNo, trackResult.StatusCode, trackResult.ResponseBody); + return result; } + + result.Success = true; + result.Message = "ok"; + return result; } catch (Exception ex) { + result.Success = false; + result.FailStep ??= "exception"; + result.Message = ex.Message; logger.LogWarning(ex, "Big-bag track sync failed for pickup {PickupNo}", bagNo); + return result; } } diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs index bd33958..9020543 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs @@ -1005,6 +1005,16 @@ public class PickupOrderService : ApplicationService }; } + /// + /// 手动创建大包轨迹订单:按序 UpsertBigBag → Link → Track/Upsert(A0201),返回逐步请求/响应。 + /// + [HttpPost("/order/pickup-order/track-upsert-big-bag")] + public async Task TrackUpsertBigBagAsync([FromBody] PickupOrderIdInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + return await PickupBigBagTrackSyncer.SyncDebugAsync(pickup); + } + /// /// 凭证:优先揽收单 CarrierCode 作为 supplierCode;AppKey/Secret 仍按承运服务→渠道分组解析。 /// diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs index d4f4a2e..8abb5fb 100644 --- a/src/Oms.Application/Services/Rbac/OmsUserService.cs +++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs @@ -4,6 +4,7 @@ using Fake.Rbac.Application.Dtos.Common; using Fake.Rbac.Application.Dtos.Role; using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Services; +using Fake.Rbac.Domain.Managers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Fake.ObjectStorage; @@ -23,6 +24,7 @@ namespace Oms.Application.Services.Rbac; [ApiExplorerSettings(GroupName = "RBAC-User")] public class OmsUserService( UserService userService, + AccountManager accountManager, UserProfileRepository profileRepository, IEmployeeRepository employeeRepository, IObjectStorage fileStorage) : ApplicationService @@ -167,6 +169,34 @@ public class OmsUserService( CancellationToken cancellationToken = default) => userService.UpdatePasswordAsync(id, input, cancellationToken); + /// + /// 管理员重置用户密码(无需旧密码)。 + /// + [HttpPost("/rbac/user/reset-password")] + public async Task ResetPasswordAsync( + [FromQuery] Guid id, + [FromBody] ResetUserPasswordInput input, + CancellationToken cancellationToken = default) + { + if (id == Guid.Empty) + { + throw new BusinessException("用户 Id 无效"); + } + + var newPassword = input?.NewPassword?.Trim(); + if (string.IsNullOrWhiteSpace(newPassword)) + { + throw new BusinessException("新密码不能为空"); + } + + if (newPassword.Length < 6) + { + throw new BusinessException("密码长度不能少于6位"); + } + + await accountManager.ResetPasswordAsync(id, newPassword, cancellationToken); + } + /// /// 更新用户头像。 /// -- 2.45.2 From ddb8efba158cb200904eab0100796cf52f93700e Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 24 Sep 2026 16:12:39 +0800 Subject: [PATCH 05/41] =?UTF-8?q?feat:=209610=E6=B1=87=E6=80=BB=E4=B8=8B?= =?UTF-8?q?=E5=8D=95=E5=85=B3=E8=81=94=E5=87=BA=E5=8F=A3=E8=B5=84=E8=B4=A8?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E8=A1=A5=E9=BD=90=E6=89=B9=E9=87=8F=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下单/导入在 9610Summary 时校验并落库 ExportQualificationId(可回落默认备案),写入 9610 申报企业信息;专线小包模板长宽高改必填并增加资质备案列。 Co-authored-by: Cursor --- db.sql | 2 + sql/migrate_order_export_qualification_id.sql | 24 +++++++++ .../CustomerExportQualificationDtos.cs | 9 ++-- .../Dtos/Orders/OrderOutput.cs | 5 ++ .../Dtos/Orders/OrderParcelCreateInput.cs | 5 ++ .../CustomerExportQualificationService.cs | 27 +++++++--- .../Services/Orders/Customs9610OrderWriter.cs | 54 ++++++++++++++++++- .../Services/Orders/OrderService.Helpers.cs | 1 + .../Orders/OrderService.ParcelBilling.cs | 36 +++++++++++++ .../Templates/OrderTemplateFieldRegistry.cs | 18 ++++--- .../Templates/OrderTemplateHeaderMatcher.cs | 4 ++ .../Templates/OrderTemplateRowMapper.cs | 21 +++++++- .../ICustomerExportQualificationRepository.cs | 4 ++ .../OrdersAggregate/Entities/Order.cs | 6 +++ .../CustomerExportQualificationRepository.cs | 7 +++ 15 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 sql/migrate_order_export_qualification_id.sql diff --git a/db.sql b/db.sql index 72e747b..a800793 100644 --- a/db.sql +++ b/db.sql @@ -124,6 +124,7 @@ CREATE TABLE `t_order` ( `ConsigneeCountryName` VARCHAR(64) NULL COMMENT '国家中文名称', `CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive', `ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式', + `ExportQualificationId` INT NULL COMMENT '出口资质备案 Id(t_customer_export_qualification.Id)', `PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因', `AllocationPendingReason` VARCHAR(512) NULL COMMENT '待排货原因(排货或渠道买价未匹配)', `AllocationScheme` VARCHAR(32) NULL COMMENT '最近一次排货方案 costLowest/timeFastest/overallBest/designated', @@ -204,6 +205,7 @@ CREATE TABLE `t_order` ( KEY `idx_ord_source_ctime` (`OrderSource`, `CreateTime`), KEY `idx_order_pickup` (`PickupOrderId`), KEY `idx_order_inbound_method` (`InboundMethod`), + KEY `idx_order_export_qualification` (`ExportQualificationId`), KEY `idx_ord_fulfillment_customer` (`FulfillmentType`, `CustomerId`, `OrderStatus`) ) COMMENT='订单表'; diff --git a/sql/migrate_order_export_qualification_id.sql b/sql/migrate_order_export_qualification_id.sql new file mode 100644 index 0000000..7cb5d9b --- /dev/null +++ b/sql/migrate_order_export_qualification_id.sql @@ -0,0 +1,24 @@ +-- 存量库:t_order 增加出口资质备案 Id(9610Summary 选用) +-- 可重复执行。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order' + AND COLUMN_NAME = 'ExportQualificationId' +); + +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order` + ADD COLUMN `ExportQualificationId` INT NULL COMMENT ''出口资质备案 Id(t_customer_export_qualification.Id)'' AFTER `ExportCustomsMode`, + ADD KEY `idx_order_export_qualification` (`ExportQualificationId`)', + 'SELECT ''t_order.ExportQualificationId already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs b/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs index d068309..a0268b8 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs @@ -48,18 +48,21 @@ public class CreateCustomerExportQualificationInput [StringLength(200)] public string DomesticConsignor { get; set; } = string.Empty; + [Required] [StringLength(64)] - public string? DomesticConsignorCustomsCode { get; set; } + public string DomesticConsignorCustomsCode { get; set; } = string.Empty; [Required] [StringLength(200)] public string ProducerName { get; set; } = string.Empty; + [Required] [StringLength(64)] - public string? ProducerCustomsCode { get; set; } + public string ProducerCustomsCode { get; set; } = string.Empty; + [Required] [StringLength(100)] - public string? CustomsPort { get; set; } + public string CustomsPort { get; set; } = string.Empty; public bool IsDefault { get; set; } diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs index 47b72f4..a82aea9 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs @@ -205,6 +205,11 @@ public class OrderOutput /// public string? ExportCustomsMode { get; set; } + /// + /// 出口资质备案 Id。 + /// + public int? ExportQualificationId { get; set; } + /// /// 预报失败原因(草稿订单)。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs index f3048f6..14ab496 100644 --- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs @@ -45,6 +45,11 @@ public class OrderParcelCreateInput /// public string? ExportCustomsMode { get; set; } + /// + /// 出口资质备案 Id(ExportCustomsMode=9610Summary 时必填)。 + /// + public int? ExportQualificationId { get; set; } + /// /// 仓库主键。 /// diff --git a/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs b/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs index 4818a7b..16e86e0 100644 --- a/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs +++ b/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs @@ -86,10 +86,10 @@ public class CustomerExportQualificationService : ApplicationService CompanyName = input.CompanyName.Trim(), SocialCreditCode = input.SocialCreditCode.Trim(), DomesticConsignor = input.DomesticConsignor.Trim(), - DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode), + DomesticConsignorCustomsCode = input.DomesticConsignorCustomsCode.Trim(), ProducerName = input.ProducerName.Trim(), - ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode), - CustomsPort = TrimOrNull(input.CustomsPort), + ProducerCustomsCode = input.ProducerCustomsCode.Trim(), + CustomsPort = input.CustomsPort.Trim(), IsDefault = input.IsDefault ? (byte)1 : (byte)0, Remark = TrimOrNull(input.Remark) }; @@ -115,10 +115,10 @@ public class CustomerExportQualificationService : ApplicationService entity.CompanyName = input.CompanyName.Trim(); entity.SocialCreditCode = input.SocialCreditCode.Trim(); entity.DomesticConsignor = input.DomesticConsignor.Trim(); - entity.DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode); + entity.DomesticConsignorCustomsCode = input.DomesticConsignorCustomsCode.Trim(); entity.ProducerName = input.ProducerName.Trim(); - entity.ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode); - entity.CustomsPort = TrimOrNull(input.CustomsPort); + entity.ProducerCustomsCode = input.ProducerCustomsCode.Trim(); + entity.CustomsPort = input.CustomsPort.Trim(); entity.IsDefault = input.IsDefault ? (byte)1 : (byte)0; entity.Remark = TrimOrNull(input.Remark); @@ -183,10 +183,25 @@ public class CustomerExportQualificationService : ApplicationService throw new BusinessException("境内发货人不能为空"); } + if (string.IsNullOrWhiteSpace(input.DomesticConsignorCustomsCode)) + { + throw new BusinessException("境内发货人海关代码不能为空"); + } + if (string.IsNullOrWhiteSpace(input.ProducerName)) { throw new BusinessException("生产销售单位不能为空"); } + + if (string.IsNullOrWhiteSpace(input.ProducerCustomsCode)) + { + throw new BusinessException("生产销售单位海关代码不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.CustomsPort)) + { + throw new BusinessException("备案海关港口地不能为空"); + } } private static Expression> BuildPredicate( diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs index f0a4f1d..22b9437 100644 --- a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs +++ b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs @@ -1,4 +1,6 @@ using Fake.DependencyInjection; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; @@ -13,15 +15,18 @@ public class Customs9610OrderWriter : IScopedDependency private readonly ICustoms9610OrderRepository _headerRepository; private readonly ICustoms9610OrderItemRepository _itemRepository; private readonly IOrderRepository _orderRepository; + private readonly ICustomerExportQualificationRepository _exportQualificationRepository; public Customs9610OrderWriter( ICustoms9610OrderRepository headerRepository, ICustoms9610OrderItemRepository itemRepository, - IOrderRepository orderRepository) + IOrderRepository orderRepository, + ICustomerExportQualificationRepository exportQualificationRepository) { _headerRepository = headerRepository; _itemRepository = itemRepository; _orderRepository = orderRepository; + _exportQualificationRepository = exportQualificationRepository; } public async Task UpsertFromOrderAsync(Order order, CancellationToken cancellationToken = default) @@ -55,6 +60,7 @@ public class Customs9610OrderWriter : IScopedDependency var grossWeight = ResolveGrossWeight(weights); var serviceNo = order.ServiceNo?.Trim() ?? string.Empty; var clientNo = FirstNonEmpty(order.ClientNo, order.ServiceNo) ?? string.Empty; + var qualification = await ResolveQualificationAsync(order, cancellationToken); if (existing != null && string.Equals(existing.Status, Customs9610StatusCodes.Declared, StringComparison.OrdinalIgnoreCase)) @@ -98,6 +104,8 @@ public class Customs9610OrderWriter : IScopedDependency header.PreRecordFlag = "1"; } + ApplyQualification(header, qualification); + header.GrossWeight = grossWeight; header.Currency = FirstNonEmpty(order.DeclaredCurrency, header.Currency, "CNY")!; header.DestCountry = FirstNonEmpty(order.ConsigneeCountry, header.DestCountry) ?? string.Empty; @@ -141,6 +149,50 @@ public class Customs9610OrderWriter : IScopedDependency } } + private async Task ResolveQualificationAsync( + Order order, + CancellationToken cancellationToken) + { + if (order.CustomerId is not > 0) + { + return null; + } + + if (order.ExportQualificationId is > 0) + { + var owned = await _exportQualificationRepository.GetOwnedAsync( + order.ExportQualificationId.Value, + order.CustomerId.Value, + cancellationToken); + if (owned != null) + { + return owned; + } + } + + return await _exportQualificationRepository.GetDefaultAsync( + order.CustomerId.Value, + cancellationToken); + } + + private static void ApplyQualification(Customs9610Order header, CustomerExportQualification? qualification) + { + if (qualification == null) + { + return; + } + + header.EcomCorpCode = Truncate( + FirstNonEmpty(qualification.DomesticConsignorCustomsCode, qualification.SocialCreditCode), + 18); + header.EcomCorpName = Truncate( + FirstNonEmpty(qualification.CompanyName, qualification.DomesticConsignor), + 100); + header.ProducerCode = Truncate(qualification.ProducerCustomsCode, 18); + header.ProducerName = Truncate(qualification.ProducerName, 100); + header.ActualSellerCode = Truncate(qualification.SocialCreditCode, 18); + } + private static decimal ResolveGrossWeight(IReadOnlyList weights) { var forecast = weights.FirstOrDefault(x => diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 7d1d00b..9444025 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -200,6 +200,7 @@ public partial class OrderService : null, RequestId = order.RequestId, ExportCustomsMode = order.ExportCustomsMode, + ExportQualificationId = order.ExportQualificationId, PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason) ? null : order.PredeclareFailReason.Trim(), diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index fb58dee..ab6f286 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -40,6 +40,8 @@ public partial class OrderService private static readonly ConcurrentDictionary InboundBillingInFlight = new(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerExportQualificationRepository ExportQualificationRepository => + LazyServiceProvider.GetRequiredService(); private IMemberLevelRepository MemberLevelRepository => LazyServiceProvider.GetRequiredService(); @@ -409,6 +411,39 @@ public partial class OrderService throw new BusinessException("出口报关方式不合法"); } + var is9610Summary = string.Equals( + input.ExportCustomsMode, + ExportCustomsModeCodes.Mode9610Summary, + StringComparison.OrdinalIgnoreCase); + if (is9610Summary) + { + CustomerExportQualification? qualification = null; + if (input.ExportQualificationId is > 0) + { + qualification = await ExportQualificationRepository.GetOwnedAsync( + input.ExportQualificationId.Value, + input.CustomerId!.Value); + if (qualification == null) + { + throw new BusinessException("出口资质备案不存在或不属于当前客户"); + } + } + else + { + qualification = await ExportQualificationRepository.GetDefaultAsync(input.CustomerId!.Value); + if (qualification == null) + { + throw new BusinessException("9610汇总申报必须选择出口资质备案(或先维护默认备案)"); + } + + input.ExportQualificationId = qualification.Id; + } + } + else + { + input.ExportQualificationId = null; + } + if (string.IsNullOrWhiteSpace(input.DeclaredCurrency)) { throw new BusinessException("申报币种不能为空"); @@ -1906,6 +1941,7 @@ public partial class OrderService ConsigneeCountryName = consigneeCountryName, CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General, ExportCustomsMode = input.ExportCustomsMode, + ExportQualificationId = input.ExportQualificationId, WarehouseId = input.WarehouseId, CustomerId = input.CustomerId, OrderSource = orderSource, diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs index ba2043d..cb8505c 100644 --- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs @@ -110,10 +110,11 @@ public static class OrderTemplateFieldRegistry Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择发货仓库 ID"); Add("actualWeight", "实重(kg)", true, "number", example: "1.2", hint: "包裹实际重量,单位千克"); Add("quantity", "包裹件数", true, "number", example: "1", hint: "一般为 1"); - Add("length", "长(cm)", false, "number", example: "10", hint: "选填,用于材积计费"); - Add("width", "宽(cm)", false, "number", example: "10", hint: "选填,用于材积计费"); - Add("height", "高(cm)", false, "number", example: "10", hint: "选填,用于材积计费"); - Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报"); + Add("length", "长(cm)", true, "number", example: "10", hint: "必填,用于材积计费"); + Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,用于材积计费"); + Add("height", "高(cm)", true, "number", example: "10", hint: "必填,用于材积计费"); + Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报;填 9610Summary 时需有默认出口资质或填备案Id"); + Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案"); Add("receiver.recipientsName", "收件人姓名", true, group: "receiver", example: "John Smith", hint: "必填"); Add("receiver.phone", "收件人手机", true, group: "receiver", example: "13900139000", hint: "必填"); @@ -167,11 +168,12 @@ public static class OrderTemplateFieldRegistry Add("clientNo", "客户单号", true, example: "C202604230001", hint: "您自己的订单编号,同一客户不可重复"); Add("productCode", "运输方式", true, "select", "sales_product", "basic", "PROD001", hint: "从下拉选择产品编码"); Add("cargoAttribute", "货物属性", true, "select", "cargo_attribute", "basic", "general", hint: "普货选 general"); - Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报"); + Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报;填 9610Summary 时需有默认出口资质或填备案Id"); + Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案"); Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择仓库 ID"); - Add("length", "长(cm)", false, "number", example: "10", hint: "材积计费用"); - Add("width", "宽(cm)", false, "number", example: "10", hint: "材积计费用"); - Add("height", "高(cm)", false, "number", example: "10", hint: "材积计费用"); + Add("length", "长(cm)", true, "number", example: "10", hint: "必填,材积计费用"); + Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,材积计费用"); + Add("height", "高(cm)", true, "number", example: "10", hint: "必填,材积计费用"); Add("actualWeight", "实重(kg)", true, "number", example: "1.2", hint: "必填"); Add("isCod", "是否COD", false, "boolean", example: "false", hint: "货到付款填 true"); Add("codCurrency", "COD币种", false, "select", "currency", example: "USD", hint: "COD 订单填写"); diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs index 76830e7..7ba39ba 100644 --- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs @@ -20,6 +20,10 @@ public static class OrderTemplateHeaderMatcher ["产品编码"] = "productCode", ["货物属性"] = "cargoAttribute", ["出口报关方式"] = "exportCustomsMode", + ["出口报关"] = "exportCustomsMode", + ["出口资质备案Id"] = "exportQualificationId", + ["出口资质备案"] = "exportQualificationId", + ["资质备案Id"] = "exportQualificationId", ["仓库"] = "warehouseId", ["仓库ID"] = "warehouseId", ["长cm"] = "length", diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs index e9146fb..b1470a6 100644 --- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs @@ -27,7 +27,7 @@ public static class OrderTemplateRowMapper private static readonly HashSet ForceNumberFields = new(StringComparer.OrdinalIgnoreCase) { - "warehouseId", "length", "width", "height", "actualWeight", "codSum", "declaredSum", "quantity", + "warehouseId", "exportQualificationId", "length", "width", "height", "actualWeight", "codSum", "declaredSum", "quantity", "receiver.taxType", "receiver.certificateType", "items[0].unitPrice", "items[0].unitWeight", "items[0].quantity", "items[0].amount", "items[0].weight" }; @@ -75,9 +75,28 @@ public static class OrderTemplateRowMapper } ApplyDerivedDeclarationFields(input); + ApplyParcelDimensionDefaults(input); return input; } + private static void ApplyParcelDimensionDefaults(OrderParcelCreateInput input) + { + if (!input.Length.HasValue || input.Length <= 0) + { + input.Length = 10; + } + + if (!input.Width.HasValue || input.Width <= 0) + { + input.Width = 10; + } + + if (!input.Height.HasValue || input.Height <= 0) + { + input.Height = 10; + } + } + private static void ApplyDerivedDeclarationFields(OrderParcelCreateInput input) { if (string.IsNullOrWhiteSpace(input.DeclaredCurrency)) diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs index 2745c10..a017633 100644 --- a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs +++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs @@ -10,6 +10,10 @@ public interface ICustomerExportQualificationRepository : IRepository GetDefaultAsync( + int customerId, + CancellationToken cancellationToken = default); + Task ClearDefaultAsync( int customerId, int? excludeId = null, diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index f58d638..611dd00 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -52,6 +52,12 @@ public class Order : OmsAuditedAggregateRoot public string? ExportCustomsMode { get; set; } + /// + /// 出口资质备案 Id(9610 汇总申报选用)。 + /// + [SugarColumn(IsNullable = true)] + public int? ExportQualificationId { get; set; } + public string? PredeclareFailReason { get; set; } /// diff --git a/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs index 6852a27..f4886cc 100644 --- a/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs +++ b/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs @@ -18,6 +18,13 @@ public class CustomerExportQualificationRepository return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken); } + public Task GetDefaultAsync( + int customerId, + CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.CustomerId == customerId && x.IsDefault == 1, cancellationToken); + } + public async Task ClearDefaultAsync( int customerId, int? excludeId = null, -- 2.45.2 From 6c8f7cb7811da37577fd10d641b9af1643f934dc Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 28 Sep 2026 14:25:11 +0800 Subject: [PATCH 06/41] =?UTF-8?q?feat:=209610=E6=B1=87=E6=80=BB=E4=B8=8B?= =?UTF-8?q?=E5=8D=95=E5=BF=85=E5=A1=AB=E7=94=B5=E5=95=86=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=86=99=E5=85=A5=E9=A2=84=E6=8A=A5=EF=BC=8C=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=AE=98=E7=BD=91=E4=BA=A7=E5=93=81=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../SalesProductPortalCatalogOutput.cs | 43 +++- .../Dtos/Orders/OrderParcelCreateInput.cs | 5 + .../SmallBagTrack/PickupBigBagTrackSyncer.cs | 8 +- .../Channels/SalesProductService.Portal.cs | 192 +++++++++++++++++- .../Services/Orders/Customs9610OrderWriter.cs | 12 +- .../Orders/OrderService.ParcelBilling.cs | 20 +- .../Templates/OrderTemplateFieldRegistry.cs | 6 +- 7 files changed, 269 insertions(+), 17 deletions(-) diff --git a/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs index fff802a..840ca84 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs @@ -6,10 +6,15 @@ namespace Oms.Application.Dtos.Channels; public class SalesProductPortalCatalogOutput { /// - /// 分组列表(已排除国内揽收,且仅含有产品的分组)。 + /// 分组列表(字典 sales_product_group,已排除国内揽收,且仅含有产品的分组)。 /// public List Groups { get; set; } = []; + /// + /// 货型筛选项(字典 cargo_attribute)。 + /// + public List Cargos { get; set; } = []; + /// /// 启用中的销售产品(已排除国内揽收)。 /// @@ -22,12 +27,28 @@ public class SalesProductPortalCatalogOutput public class SalesProductPortalGroupOutput { /// - /// 分组编码(字典 code)。 + /// 分组编码(字典 sales_product_group.code)。 /// public string Code { get; set; } = string.Empty; /// - /// 分组名称。 + /// 分组名称(字典 sales_product_group.name)。 + /// + public string Name { get; set; } = string.Empty; +} + +/// +/// 官网货型筛选项。 +/// +public class SalesProductPortalCargoOutput +{ + /// + /// 货型编码(字典 cargo_attribute.code)。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 货型名称(字典 cargo_attribute.name)。 /// public string Name { get; set; } = string.Empty; } @@ -81,4 +102,20 @@ public class SalesProductPortalItemOutput /// 产品说明。 /// public string? ProductDescription { get; set; } + + /// + /// 官网目的地区域编码:us / eu / sea / ca_mx / au / global。 + /// 优先由价格分区国家推导;国际快递跨区时含 global。 + /// + public List DestRegions { get; set; } = []; + + /// + /// 覆盖国家二字码(来自价格分区明细,去重后截断)。 + /// + public List CoverageCountries { get; set; } = []; + + /// + /// 货型标签(字典 cargo_attribute.code,如 general / electronics / sensitive)。 + /// + public List CargoTags { get; set; } = []; } diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs index 14ab496..653924a 100644 --- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs @@ -50,6 +50,11 @@ public class OrderParcelCreateInput /// public int? ExportQualificationId { get; set; } + /// + /// 电商平台(ExportCustomsMode=9610Summary 时必填,写入 t_9610_order.PlatformCode)。 + /// + public string? PlatformCode { get; set; } + /// /// 仓库主键。 /// diff --git a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs index fc63b2c..7081b08 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Oms.Domain.ChannelsAggregate.Repositories; -using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; @@ -43,7 +42,6 @@ public interface IPickupBigBagTrackSyncer internal sealed class PickupBigBagTrackSyncer( ISmallBagTrackOrderClient trackClient, - ICustomerRepository customerRepository, IPickupOrderLineRepository pickupOrderLineRepository, ISalesProductRepository salesProductRepository, IServiceChannelRepository serviceChannelRepository, @@ -85,16 +83,14 @@ internal sealed class PickupBigBagTrackSyncer( try { - var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == pickup.CustomerId, cancellationToken); - var clientCode = customer?.CustomerCode?.Trim() ?? string.Empty; var (channel, product) = await ResolveChannelProductAsync(pickup.CarrierService, cancellationToken); var country = ResolveCountry(pickup); - // 1) UpsertBigBag + // 1) UpsertBigBag:client 取 product(销售产品编码) var upsertResult = await trackClient.UpsertBigBagAsync( new SmallBagTrackUpsertBigBagRequest { - Client = clientCode, + Client = product ?? string.Empty, BagNo = bagNo, OtherNo = pickup.CarrierWaybillNo, Channel = channel, diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs b/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs index 12c0ed9..5599706 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Channels; +using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.Shared.Enums; using Oms.Domain.SystemAggregate.Repositories; @@ -9,8 +10,22 @@ namespace Oms.Application.Services.Channels; public partial class SalesProductService { private const string SalesProductGroupDictType = "sales_product_group"; + private const string CargoAttributeDictType = "cargo_attribute"; private const string UngroupedProductGroupCode = "_ungrouped"; private const string DomesticPickupGroupNameKeyword = "国内揽收"; + private const string ExpressProductGroupCode = "express"; + private const int PortalCoverageCountryLimit = 40; + + private static readonly HashSet PortalEuCountryCodes = new(StringComparer.OrdinalIgnoreCase) + { + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", + "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "UK" + }; + + private static readonly HashSet PortalSeaCountryCodes = new(StringComparer.OrdinalIgnoreCase) + { + "MY", "SG", "PH", "TH", "VN", "ID" + }; private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService(); @@ -24,6 +39,7 @@ public partial class SalesProductService { var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled); var dictRows = await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType); + var cargoDictRows = await DictRepository.GetListAsync(x => x.DictType == CargoAttributeDictType); var domesticPickupGroupCodes = dictRows .Where(x => !string.IsNullOrWhiteSpace(x.DictName) @@ -59,6 +75,26 @@ public partial class SalesProductService .ThenBy(p => p.Id) .ToList(); + var areaIds = filtered + .Select(p => p.PriceAreaId) + .Where(id => id is > 0) + .Select(id => id!.Value) + .Distinct() + .ToList(); + + var countriesByAreaId = new Dictionary>(); + foreach (var areaId in areaIds) + { + var infos = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId); + var codes = infos + .Select(x => (x.CountryCode ?? string.Empty).Trim().ToUpperInvariant()) + .Where(code => code.Length == 2) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(code => code, StringComparer.OrdinalIgnoreCase) + .ToList(); + countriesByAreaId[areaId] = codes; + } + var usedGroupCodes = new HashSet(StringComparer.OrdinalIgnoreCase); var items = filtered.Select(p => { @@ -67,6 +103,11 @@ public partial class SalesProductService : p.ProductGroup.Trim(); usedGroupCodes.Add(groupCode); groupNameMap.TryGetValue(groupCode, out var groupName); + + var coverageCountries = ResolvePortalCoverageCountries(p, countriesByAreaId); + var destRegions = ResolvePortalDestRegions(p, groupCode, coverageCountries); + var cargoTags = ResolvePortalCargoTags(p); + return new SalesProductPortalItemOutput { Id = p.Id, @@ -77,7 +118,10 @@ public partial class SalesProductService MaxTimeliness = p.MaxTimeliness, TradeTerms = string.IsNullOrWhiteSpace(p.TradeTerms) ? null : p.TradeTerms.Trim().ToUpperInvariant(), RouteSort = p.RouteSort, - ProductDescription = p.ProductDescription + ProductDescription = p.ProductDescription, + DestRegions = destRegions, + CoverageCountries = coverageCountries.Take(PortalCoverageCountryLimit).ToList(), + CargoTags = cargoTags }; }).ToList(); @@ -121,10 +165,156 @@ public partial class SalesProductService }); } + var cargos = cargoDictRows + .Where(x => !string.IsNullOrWhiteSpace(x.DictCode)) + .GroupBy(x => x.DictCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(g => g.OrderBy(x => x.DictSort).ThenBy(x => x.DictCode).First()) + .OrderBy(x => x.DictSort) + .ThenBy(x => x.DictCode) + .Select(x => new SalesProductPortalCargoOutput + { + Code = x.DictCode!.Trim(), + Name = string.IsNullOrWhiteSpace(x.DictName) ? x.DictCode!.Trim() : x.DictName.Trim() + }) + .ToList(); + return new SalesProductPortalCatalogOutput { Groups = groups, + Cargos = cargos, Items = items }; } + + private static List ResolvePortalCoverageCountries( + SalesProduct product, + IReadOnlyDictionary> countriesByAreaId) + { + if (product.PriceAreaId is not > 0) + { + return []; + } + + return countriesByAreaId.TryGetValue(product.PriceAreaId.Value, out var codes) + ? codes + : []; + } + + private static List ResolvePortalDestRegions( + SalesProduct product, + string groupCode, + IReadOnlyCollection coverageCountries) + { + var regions = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var code in coverageCountries) + { + if (string.Equals(code, "US", StringComparison.OrdinalIgnoreCase)) + { + regions.Add("us"); + continue; + } + + if (string.Equals(code, "CA", StringComparison.OrdinalIgnoreCase) + || string.Equals(code, "MX", StringComparison.OrdinalIgnoreCase)) + { + regions.Add("ca_mx"); + continue; + } + + if (string.Equals(code, "AU", StringComparison.OrdinalIgnoreCase) + || string.Equals(code, "NZ", StringComparison.OrdinalIgnoreCase)) + { + regions.Add("au"); + continue; + } + + if (PortalSeaCountryCodes.Contains(code)) + { + regions.Add("sea"); + continue; + } + + if (PortalEuCountryCodes.Contains(code)) + { + regions.Add("eu"); + } + } + + // 国际快递:价格分区跨多区,或分区为空时仍作为全球快递备选 + var isExpress = string.Equals(groupCode, ExpressProductGroupCode, StringComparison.OrdinalIgnoreCase) + || ContainsAnyIgnoreCase(product.ProductName, "DHL", "UPS", "FedEx", "FEDEX", "3PE"); + if (isExpress && (regions.Count >= 2 || coverageCountries.Count == 0 || coverageCountries.Count >= 12)) + { + regions.Add("global"); + } + + // 无价格分区时,用产品名做轻量兜底(与官网前端关键词对齐) + if (regions.Count == 0) + { + var blob = $"{product.ProductName} {product.ProductDescription}"; + if (ContainsAnyIgnoreCase(blob, "美国", "USA", "United States", "美东", "美西")) regions.Add("us"); + if (ContainsAnyIgnoreCase(blob, "东南", "越南", "马来", "新加坡", "泰国", "菲律宾", "印尼")) regions.Add("sea"); + if (ContainsAnyIgnoreCase(blob, "欧盟", "欧洲", "英国", "德国", "法国")) regions.Add("eu"); + if (ContainsAnyIgnoreCase(blob, "加拿大", "墨西哥")) regions.Add("ca_mx"); + if (ContainsAnyIgnoreCase(blob, "澳洲", "澳大利亚", "新西兰")) regions.Add("au"); + if (isExpress) regions.Add("global"); + } + + return regions + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// 将产品说明映射到字典 cargo_attribute 编码(general / electronics / sensitive)。 + /// 销售产品本身暂无货型字段,官网先按说明关键词归类,便于筛选项与字典一致。 + /// + private static List ResolvePortalCargoTags(SalesProduct product) + { + var blob = $"{product.ProductName} {product.ProductDescription}"; + var tags = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (ContainsAnyIgnoreCase(blob, "带电", "电池", "锂电", "3C", "lithium", "battery", "electronics")) + { + tags.Add("electronics"); + } + + if (ContainsAnyIgnoreCase( + blob, + "化妆品", + "cosmetic", + "敏感", + "特殊", + "疑难", + "超长", + "超重", + "超大", + "oversize", + "overweight", + "special", + "sensitive")) + { + tags.Add("sensitive"); + } + + if (ContainsAnyIgnoreCase(blob, "普货", "general cargo", "general")) + { + tags.Add("general"); + } + + return tags + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool ContainsAnyIgnoreCase(string? text, params string[] needles) + { + if (string.IsNullOrWhiteSpace(text) || needles.Length == 0) + { + return false; + } + + return needles.Any(n => text.Contains(n, StringComparison.OrdinalIgnoreCase)); + } } diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs index 22b9437..14da193 100644 --- a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs +++ b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs @@ -29,7 +29,10 @@ public class Customs9610OrderWriter : IScopedDependency _exportQualificationRepository = exportQualificationRepository; } - public async Task UpsertFromOrderAsync(Order order, CancellationToken cancellationToken = default) + public async Task UpsertFromOrderAsync( + Order order, + string? platformCode = null, + CancellationToken cancellationToken = default) { if (order.Id <= 0) { @@ -106,6 +109,13 @@ public class Customs9610OrderWriter : IScopedDependency ApplyQualification(header, qualification); + // 下单传入的电商平台优先;刷新时若未传则保留已有值 + var normalizedPlatform = Truncate(FirstNonEmpty(platformCode, header.PlatformCode), 16); + if (!string.IsNullOrWhiteSpace(normalizedPlatform)) + { + header.PlatformCode = normalizedPlatform; + } + header.GrossWeight = grossWeight; header.Currency = FirstNonEmpty(order.DeclaredCurrency, header.Currency, "CNY")!; header.DestCountry = FirstNonEmpty(order.ConsigneeCountry, header.DestCountry) ?? string.Empty; diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index ab6f286..993df8f 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -142,7 +142,7 @@ public partial class OrderService // 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。 if (input.SaveAsDraft) { - await TryUpsertCustoms9610Async(order); + await TryUpsertCustoms9610Async(order, input.PlatformCode); return await BuildParcelCreateOutputAsync(order); } @@ -158,7 +158,7 @@ public partial class OrderService await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); SyncWeightRows(weights, forecast); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); - await TryUpsertCustoms9610Async(order); + await TryUpsertCustoms9610Async(order, input.PlatformCode); var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId); var priceTierKey = await ResolvePriceTierKeyAsync(order.CustomerId); @@ -261,12 +261,12 @@ public partial class OrderService await OrderRepository.DeleteAsync(order); } - private async Task TryUpsertCustoms9610Async(Order order) + private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null) { try { await LazyServiceProvider.GetRequiredService() - .UpsertFromOrderAsync(order); + .UpsertFromOrderAsync(order, platformCode); } catch { @@ -438,10 +438,22 @@ public partial class OrderService input.ExportQualificationId = qualification.Id; } + + if (string.IsNullOrWhiteSpace(input.PlatformCode)) + { + throw new BusinessException("9610汇总申报必须填写电商平台"); + } + + input.PlatformCode = input.PlatformCode.Trim(); + if (input.PlatformCode.Length > 16) + { + throw new BusinessException("电商平台长度不能超过 16"); + } } else { input.ExportQualificationId = null; + input.PlatformCode = null; } if (string.IsNullOrWhiteSpace(input.DeclaredCurrency)) diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs index cb8505c..b5be7bd 100644 --- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs @@ -113,8 +113,9 @@ public static class OrderTemplateFieldRegistry Add("length", "长(cm)", true, "number", example: "10", hint: "必填,用于材积计费"); Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,用于材积计费"); Add("height", "高(cm)", true, "number", example: "10", hint: "必填,用于材积计费"); - Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报;填 9610Summary 时需有默认出口资质或填备案Id"); + Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报;填 9610Summary 时需有默认出口资质或填备案Id,并填电商平台"); Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案"); + Add("platformCode", "电商平台", false, example: "Amazon", hint: "9610汇总申报时必填,写入海关申报电商平台"); Add("receiver.recipientsName", "收件人姓名", true, group: "receiver", example: "John Smith", hint: "必填"); Add("receiver.phone", "收件人手机", true, group: "receiver", example: "13900139000", hint: "必填"); @@ -168,8 +169,9 @@ public static class OrderTemplateFieldRegistry Add("clientNo", "客户单号", true, example: "C202604230001", hint: "您自己的订单编号,同一客户不可重复"); Add("productCode", "运输方式", true, "select", "sales_product", "basic", "PROD001", hint: "从下拉选择产品编码"); Add("cargoAttribute", "货物属性", true, "select", "cargo_attribute", "basic", "general", hint: "普货选 general"); - Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报;填 9610Summary 时需有默认出口资质或填备案Id"); + Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报;填 9610Summary 时需有默认出口资质或填备案Id,并填电商平台"); Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案"); + Add("platformCode", "电商平台", false, example: "Amazon", hint: "9610汇总申报时必填,写入海关申报电商平台"); Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择仓库 ID"); Add("length", "长(cm)", true, "number", example: "10", hint: "必填,材积计费用"); Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,材积计费用"); -- 2.45.2 From 8226542ffd1dd9eff1ed76a3d4b3a3692425d6de Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 28 Sep 2026 14:33:52 +0800 Subject: [PATCH 07/41] =?UTF-8?q?fix:=209610=E9=A2=84=E6=8A=A5=E4=B8=8B?= =?UTF-8?q?=E6=B8=B8=E7=BD=91=E5=85=B3=E5=9C=B0=E5=9D=80=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=8F=96=E8=87=AA=20api=5Fcenter=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Services/Orders/Customs9610OrderService.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs index 57b3d84..7a6a3a7 100644 --- a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs +++ b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs @@ -242,7 +242,7 @@ public class Customs9610OrderService : ApplicationService } /// - /// 预报下游(写死 create9610Order / BH 网关地址)。 + /// 预报下游(create9610Order / BH;网关地址取自其他配置 api_center)。 /// [HttpPost("/order/customs-9610/fetch-declaration-form-batch")] public async Task FetchDeclarationFormBatchAsync([FromBody] Customs9610IdsInput input) @@ -274,10 +274,10 @@ public class Customs9610OrderService : ApplicationService } /// - /// 写死的 9610 预报下游地址:create9610Order + supplierCode=BH。 + /// 9610 预报下游标准码 / 供应商码(网关根地址由 api_center.BaseUrl 拼装)。 /// - private const string Create9610OrderForwardUrl = - "https://api.ideship.com/itfx/InterfaceForward/ForwardWithSubscribe?standardCode=create9610Order&supplierCode=BH"; + private const string Create9610OrderStandardCode = "create9610Order"; + private const string Create9610OrderSupplierCode = "BH"; private async Task DownstreamForwardOneAsync(int id) { @@ -303,8 +303,10 @@ public class Customs9610OrderService : ApplicationService ItfxForwardResult forward; try { - forward = await ItfxForwardClient.ForwardToAbsoluteUrlAsync( - Create9610OrderForwardUrl, + // BaseUrl 来自其他配置 api_center(ItfxForwardClient 内部解析) + forward = await ItfxForwardClient.ForwardWithSubscribeAsync( + Create9610OrderStandardCode, + Create9610OrderSupplierCode, creds.AppKey, creds.AppSecret, body); @@ -322,7 +324,6 @@ public class Customs9610OrderService : ApplicationService Status = header.Status, ServiceNo = header.ServiceNo, ClientNo = header.ClientNo, - Url = Create9610OrderForwardUrl, }; } -- 2.45.2 From 3bde909238f9d82582b4a309fdff8fa0edad5df3 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 28 Sep 2026 16:58:00 +0800 Subject: [PATCH 08/41] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=A7?= =?UTF-8?q?=E5=8C=85=E8=BD=A8=E8=BF=B9=E5=90=8C=E6=AD=A5=E4=B8=8B=E5=8F=91?= =?UTF-8?q?=20apiCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../SmallBagTrack/PickupBigBagTrackSyncer.cs | 45 ++++++++++++++++--- .../SmallBagTrack/SmallBagTrackOrderClient.cs | 3 +- .../SmallBagTrackUpsertBigBagApiRequest.cs | 2 + .../SmallBagTrackUpsertBigBagRequest.cs | 5 +++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs index 7081b08..edffb5b 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using Oms.Application.Integrations.Itfx; +using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; @@ -45,6 +47,8 @@ internal sealed class PickupBigBagTrackSyncer( IPickupOrderLineRepository pickupOrderLineRepository, ISalesProductRepository salesProductRepository, IServiceChannelRepository serviceChannelRepository, + IServiceChannelGroupRepository serviceChannelGroupRepository, + IItfxMetadataClient itfxMetadataClient, IHttpContextAccessor httpContextAccessor, ILogger logger) : IPickupBigBagTrackSyncer { @@ -83,7 +87,7 @@ internal sealed class PickupBigBagTrackSyncer( try { - var (channel, product) = await ResolveChannelProductAsync(pickup.CarrierService, cancellationToken); + var (channel, product, apiCode) = await ResolveChannelProductAsync(pickup.CarrierService, cancellationToken); var country = ResolveCountry(pickup); // 1) UpsertBigBag:client 取 product(销售产品编码) @@ -96,6 +100,7 @@ internal sealed class PickupBigBagTrackSyncer( Channel = channel, Product = product, Country = country, + ApiCode = apiCode, Authorization = authorization }, cancellationToken); @@ -215,20 +220,20 @@ internal sealed class PickupBigBagTrackSyncer( } } - private async Task<(string? Channel, string? Product)> ResolveChannelProductAsync( + private async Task<(string? Channel, string? Product, string? ApiCode)> ResolveChannelProductAsync( string? carrierService, CancellationToken cancellationToken) { var salesProductCode = carrierService?.Trim(); if (string.IsNullOrWhiteSpace(salesProductCode)) { - return (null, null); + return (null, null, null); } var product = await salesProductRepository.GetByProductCodeAsync(salesProductCode, cancellationToken); if (product == null) { - return (null, salesProductCode); + return (null, salesProductCode, null); } var providers = await salesProductRepository.GetAllocationProvidersAsync(product.Id, cancellationToken); @@ -239,14 +244,42 @@ internal sealed class PickupBigBagTrackSyncer( .FirstOrDefault(); if (channelId <= 0) { - return (null, product.ProductCode); + return (null, product.ProductCode, null); } var channel = await serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId, cancellationToken); var channelCode = channel?.ChannelCode?.Trim(); + var apiCode = channel == null + ? null + : await ResolveApiCodeFromServiceChannelAsync(channel, cancellationToken); return ( string.IsNullOrWhiteSpace(channelCode) ? null : channelCode, - product.ProductCode); + product.ProductCode, + apiCode); + } + + private async Task ResolveApiCodeFromServiceChannelAsync( + ServiceChannel channel, + CancellationToken cancellationToken) + { + if (!channel.GroupId.HasValue || channel.GroupId.Value <= 0) + { + return null; + } + + var group = await serviceChannelGroupRepository.FirstOrDefaultAsync( + x => x.Id == channel.GroupId.Value, + cancellationToken); + if (group == null || group.ApiServiceProviderId <= 0) + { + return null; + } + + var serviceProvider = await itfxMetadataClient.GetServiceProviderAsync( + group.ApiServiceProviderId, + cancellationToken); + var code = serviceProvider?.Code?.Trim(); + return string.IsNullOrWhiteSpace(code) ? null : code; } private static string ResolveCountry(PickupOrder pickup) diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs index b6e5077..ecb80a1 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs @@ -260,7 +260,8 @@ internal sealed class SmallBagTrackOrderClient( OtherNo = request.OtherNo?.Trim() ?? string.Empty, Channel = request.Channel?.Trim() ?? string.Empty, Product = request.Product?.Trim() ?? string.Empty, - Country = request.Country?.Trim() ?? string.Empty + Country = request.Country?.Trim() ?? string.Empty, + ApiCode = request.ApiCode?.Trim() ?? string.Empty }; var url = SmallBagTrackUpsertBigBagApiRequest.BuildEndpointUrl(settings.BaseUrl); var authorization = ResolveAuthorization(request.Authorization); diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs index 72e1c00..a694b0c 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagApiRequest.cs @@ -17,6 +17,8 @@ internal sealed class SmallBagTrackUpsertBigBagApiRequest public string Country { get; set; } = string.Empty; + public string ApiCode { get; set; } = string.Empty; + public static string BuildEndpointUrl(string baseUrl) => $"{baseUrl.Trim().TrimEnd('/')}/ProviderService/Order/UpsertBigBag"; } diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs index 3635ec7..9ac9c1a 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertBigBagRequest.cs @@ -17,5 +17,10 @@ public class SmallBagTrackUpsertBigBagRequest public string? Country { get; set; } + /// + /// API 服务商 code(轨迹拉取配置 ServiceProviderCode)。 + /// + public string? ApiCode { get; set; } + public string? Authorization { get; set; } } -- 2.45.2 From 28366010626b54cd787f49cb4be1d36d223556b2 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 29 Sep 2026 12:17:04 +0800 Subject: [PATCH 09/41] chore: retrigger test deploy for 9610 downstream forward Co-authored-by: Cursor -- 2.45.2 From 24b8c29c9b261b7bc7531038141f6f60a47c75d4 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 29 Sep 2026 14:50:27 +0800 Subject: [PATCH 10/41] =?UTF-8?q?fix:=209610=E9=A2=84=E6=8A=A5=E4=B8=8B?= =?UTF-8?q?=E6=B8=B8=E5=9B=9E=E5=86=99=E4=B8=8B=E6=B8=B8=E5=8D=95=E5=8F=B7?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=20serviceNo/orderNo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Orders/Customs9610OrderService.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs index 7a6a3a7..2e45bea 100644 --- a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs +++ b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs @@ -982,12 +982,27 @@ public class Customs9610OrderService : ApplicationService if (root.TryGetProperty("data", out var data) || root.TryGetProperty("Data", out data)) { customsOrderNo = ReadString(data, "orderNo", "OrderNo", "customsOrderNo", "CustomsOrderNo"); - referenceNo = ReadString(data, "referenceNo", "ReferenceNo"); + // 下游单号:优先 serviceNo;兼容 referenceNo;BH create9610Order 常回 orderNo + referenceNo = ReadString( + data, + "serviceNo", + "ServiceNo", + "referenceNo", + "ReferenceNo", + "orderNo", + "OrderNo"); } else { customsOrderNo = ReadString(root, "orderNo", "OrderNo", "customsOrderNo", "CustomsOrderNo"); - referenceNo = ReadString(root, "referenceNo", "ReferenceNo"); + referenceNo = ReadString( + root, + "serviceNo", + "ServiceNo", + "referenceNo", + "ReferenceNo", + "orderNo", + "OrderNo"); } } catch -- 2.45.2 From 5fb6888b5574a91e061d66b5e65eeee554e7edaf Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 29 Sep 2026 19:19:24 +0800 Subject: [PATCH 11/41] =?UTF-8?q?feat:=20=E6=8F=BD=E6=94=B6=E6=89=BF?= =?UTF-8?q?=E8=BF=90=E6=8E=A8=E9=80=81=E4=BB=B6=E9=87=8D=E5=B0=BA=E5=B9=B6?= =?UTF-8?q?=E6=8C=89=E5=8D=96=E4=BB=B7=E4=B9=B0=E4=BB=B7=E5=87=BA=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- db.sql | 9 + sql/migrate_pickup_fee_push.sql | 11 + .../Dtos/Orders/PickupOrderDtos.cs | 103 ++++ .../Services/Orders/PickupOpenApiService.cs | 76 +++ .../Orders/PickupOrderFeePushService.cs | 515 ++++++++++++++++++ .../Services/Orders/PickupOrderService.cs | 9 + .../System/SystemOtherConfigService.cs | 7 + .../OrdersAggregate/Entities/PickupOrder.cs | 48 ++ .../Consts/SystemOtherConfigCodes.cs | 13 +- 9 files changed, 787 insertions(+), 4 deletions(-) create mode 100644 sql/migrate_pickup_fee_push.sql create mode 100644 src/Oms.Application/Services/Orders/PickupOpenApiService.cs create mode 100644 src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs diff --git a/db.sql b/db.sql index a800793..876be41 100644 --- a/db.sql +++ b/db.sql @@ -1907,6 +1907,15 @@ CREATE TABLE `t_pickup_order` ( `ParcelCount` INT NOT NULL DEFAULT 0 COMMENT '关联票数', `TotalActualWeight` DECIMAL(18,3) NULL COMMENT '汇总实重', `ChargeableWeight` DECIMAL(18,3) NULL COMMENT '计费重', + `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重', + `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON', + `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)', + `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)', + `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)', + `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文', + `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON', + `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON', + `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间', `Currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '币种', `FreightAmount` DECIMAL(18,2) NULL COMMENT '对客揽收运费', `OtherFeeAmount` DECIMAL(18,2) NULL COMMENT '对客其他费', diff --git a/sql/migrate_pickup_fee_push.sql b/sql/migrate_pickup_fee_push.sql new file mode 100644 index 0000000..539f2a0 --- /dev/null +++ b/sql/migrate_pickup_fee_push.sql @@ -0,0 +1,11 @@ +-- 揽收单:承运推送件重尺 + 系统卖价/买价费用快照 +ALTER TABLE `t_pickup_order` + ADD COLUMN `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重' AFTER `ChargeableWeight`, + ADD COLUMN `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON' AFTER `VolumetricWeight`, + ADD COLUMN `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)' AFTER `PackageSizesJson`, + ADD COLUMN `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)' AFTER `CarrierFreightWeight`, + ADD COLUMN `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)' AFTER `CarrierQuotedFreightAmount`, + ADD COLUMN `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文' AFTER `CarrierQuotedReceivableAmount`, + ADD COLUMN `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON' AFTER `CarrierQuoteJson`, + ADD COLUMN `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON' AFTER `ReceivableFeeJson`, + ADD COLUMN `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间' AFTER `PayableFeeJson`; diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs index 0706a9f..b046a71 100644 --- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs +++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs @@ -273,6 +273,24 @@ public class PickupOrderOutput public decimal? ChargeableWeight { get; set; } + public decimal? VolumetricWeight { get; set; } + + public List PackageSizes { get; set; } = []; + + public decimal? CarrierFreightWeight { get; set; } + + public decimal? CarrierQuotedFreightAmount { get; set; } + + public decimal? CarrierQuotedReceivableAmount { get; set; } + + public PickupCarrierQuoteOutput? CarrierQuote { get; set; } + + public List ReceivableFees { get; set; } = []; + + public List PayableFees { get; set; } = []; + + public DateTime? FeePushedAt { get; set; } + public string Currency { get; set; } = "CNY"; public decimal? FreightAmount { get; set; } @@ -303,3 +321,88 @@ public class PickupOrderOutput public List Rejected { get; set; } = []; } + +public class PickupPackageSizeOutput +{ + public decimal Length { get; set; } + + public decimal Width { get; set; } + + public decimal Height { get; set; } + + public int Count { get; set; } +} + +public class PickupFeeLineOutput +{ + public string CostItem { get; set; } = string.Empty; + + public decimal Amount { get; set; } + + public string Currency { get; set; } = string.Empty; +} + +public class PickupCarrierQuoteOutput +{ + public string? ServiceMode { get; set; } + + public string? PayMode { get; set; } + + public List ValueInfoList { get; set; } = []; +} + +public class PickupCarrierValueFeeOutput +{ + public string? FeeCode { get; set; } + + public string? FeeName { get; set; } + + public decimal? FeeAmount { get; set; } +} + +public class PickupOpenApiEnvelope +{ + public string Code { get; set; } = "200"; + + public string Msg { get; set; } = "成功"; + + public PickupOpenApiPushFeeResult? Data { get; set; } +} + +public class PickupOpenApiPushFeeResult +{ + public string PickupNo { get; set; } = string.Empty; + + public decimal ActualWeight { get; set; } + + public decimal ChargeableWeight { get; set; } + + public decimal? VolumetricWeight { get; set; } + + public decimal ReceivableAmount { get; set; } + + public decimal PayableAmount { get; set; } + + public decimal? CarrierQuotedFreightAmount { get; set; } +} + +public class PickupOpenApiPushFeeInput +{ + public string? WaybillNumber { get; set; } + + public decimal ActualWeight { get; set; } + + public decimal? FreightWeight { get; set; } + + public decimal? TotalFreightAmount { get; set; } + + public decimal? ReceivableAmount { get; set; } + + public List? ValueInfoList { get; set; } + + public List? GoodsSizeList { get; set; } + + public string? ServiceMode { get; set; } + + public string? PayMode { get; set; } +} diff --git a/src/Oms.Application/Services/Orders/PickupOpenApiService.cs b/src/Oms.Application/Services/Orders/PickupOpenApiService.cs new file mode 100644 index 0000000..a5a2dd4 --- /dev/null +++ b/src/Oms.Application/Services/Orders/PickupOpenApiService.cs @@ -0,0 +1,76 @@ +using Fake.Application; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; + +namespace Oms.Application.Services.Orders; + +/// +/// 国内揽收开放接口(承运商推送件重尺)。 +/// +[ApiExplorerSettings(GroupName = "OMS-Order")] +public class PickupOpenApiService : ApplicationService +{ + private PickupOrderFeePushService FeePush => + LazyServiceProvider.GetRequiredService(); + + /// + /// 开放平台路由:method=open.api.openCommon.pushFee,仅校验 appKey。 + /// + [AllowAnonymous] + [HttpPost("/openapi")] + public Task InvokeAsync( + [FromQuery] string? appKey, + [FromQuery] string? method, + [FromBody] PickupOpenApiPushFeeInput? body) => + HandlePushFeeAsync(appKey, method, body); + + /// + /// 推送揽收重量/尺寸并按当前产品计算应收应付。 + /// + [AllowAnonymous] + [HttpPost("/openapi/openCommon/pushFee")] + public Task PushFeeAsync( + [FromQuery] string? appKey, + [FromBody] PickupOpenApiPushFeeInput? body) => + HandlePushFeeAsync(appKey, PickupOrderFeePushService.PushFeeMethod, body); + + private async Task HandlePushFeeAsync( + string? appKey, + string? method, + PickupOpenApiPushFeeInput? body) + { + try + { + if (!string.IsNullOrWhiteSpace(method) + && !string.Equals(method.Trim(), PickupOrderFeePushService.PushFeeMethod, StringComparison.OrdinalIgnoreCase)) + { + return Fail("400", $"不支持的 method:{method}"); + } + + var data = await FeePush.PushFeeAsync(appKey, body); + return new PickupOpenApiEnvelope + { + Code = "200", + Msg = "成功", + Data = data + }; + } + catch (BusinessException ex) + { + return Fail("400", ex.Message); + } + catch (Exception ex) + { + return Fail("500", string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message); + } + } + + private static PickupOpenApiEnvelope Fail(string code, string msg) => + new() + { + Code = code, + Msg = msg, + Data = null + }; +} diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs new file mode 100644 index 0000000..73dbafa --- /dev/null +++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs @@ -0,0 +1,515 @@ +using Fake.Application; +using Fake.DependencyInjection; +using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Orders; +using Oms.Application.Integrations.Itfx; +using Oms.Application.Services.Orders.Issues; +using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.SystemAggregate.Consts; +using Oms.Domain.SystemAggregate.Repositories; + +namespace Oms.Application.Services.Orders; + +/// +/// 承运推送揽收件重尺后,按当前销售产品卖价/买价出账。 +/// +public class PickupOrderFeePushService : IScopedDependency +{ + public const string PushFeeMethod = "open.api.openCommon.pushFee"; + + private readonly IPickupOrderRepository _pickupOrderRepository; + private readonly ISystemOtherConfigRepository _otherConfigRepository; + private readonly ISalesProductRepository _salesProductRepository; + private readonly IServiceChannelRepository _serviceChannelRepository; + private readonly IServiceChannelGroupRepository _serviceChannelGroupRepository; + private readonly IWeightRuleRepository _weightRuleRepository; + private readonly IWeightRoundingRepository _weightRoundingRepository; + private readonly IKpiAreaRepository _kpiAreaRepository; + private readonly IWarehouseAddressRepository _warehouseAddressRepository; + private readonly IItfxMetadataClient _itfxMetadataClient; + + public PickupOrderFeePushService( + IPickupOrderRepository pickupOrderRepository, + ISystemOtherConfigRepository otherConfigRepository, + ISalesProductRepository salesProductRepository, + IServiceChannelRepository serviceChannelRepository, + IServiceChannelGroupRepository serviceChannelGroupRepository, + IWeightRuleRepository weightRuleRepository, + IWeightRoundingRepository weightRoundingRepository, + IKpiAreaRepository kpiAreaRepository, + IWarehouseAddressRepository warehouseAddressRepository, + IItfxMetadataClient itfxMetadataClient) + { + _pickupOrderRepository = pickupOrderRepository; + _otherConfigRepository = otherConfigRepository; + _salesProductRepository = salesProductRepository; + _serviceChannelRepository = serviceChannelRepository; + _serviceChannelGroupRepository = serviceChannelGroupRepository; + _weightRuleRepository = weightRuleRepository; + _weightRoundingRepository = weightRoundingRepository; + _kpiAreaRepository = kpiAreaRepository; + _warehouseAddressRepository = warehouseAddressRepository; + _itfxMetadataClient = itfxMetadataClient; + } + + public async Task PushFeeAsync(string? appKey, PickupOpenApiPushFeeInput? input) + { + var key = (appKey ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(key)) + { + throw new BusinessException("appKey 不能为空"); + } + + if (input == null) + { + throw new BusinessException("请求体不能为空"); + } + + var pickupNo = (input.WaybillNumber ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(pickupNo)) + { + throw new BusinessException("waybillNumber 不能为空"); + } + + if (input.ActualWeight <= 0) + { + throw new BusinessException("actualWeight 必须大于 0"); + } + + var sizes = NormalizeSizes(input.GoodsSizeList); + if (sizes.Count == 0) + { + throw new BusinessException("goodsSizeList 不能为空"); + } + + var pickup = await _pickupOrderRepository.GetByPickupNoAsync(pickupNo) + ?? throw new BusinessException("揽收单不存在"); + + await EnsureAppKeyAsync(key, pickup); + + if (pickup.PayStatus == PickupPayStatusCodes.Paid) + { + throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用"); + } + + if (PickupOrderStatusCodes.IsTerminal(pickup.Status) + && pickup.Status != PickupOrderStatusCodes.Inbound) + { + throw new BusinessException("当前揽收单状态不可接收件重尺"); + } + + if (string.IsNullOrWhiteSpace(pickup.CarrierService)) + { + throw new BusinessException("揽收单未选择承运服务(销售产品),无法计费"); + } + + var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim()) + ?? throw new BusinessException($"未找到承运服务对应销售产品「{pickup.CarrierService}」"); + + var totalVolume = sizes.Sum(x => x.Length * x.Width * x.Height * x.Count); + var weightRow = new OrderWeight + { + Type = OrderWeightTypeCodes.Receive, + Length = totalVolume, + Width = 1, + Height = 1, + ActualWeight = input.ActualWeight, + Cubic = ParcelBillingCalculator.CalculateCubic(totalVolume, 1, 1) + }; + + decimal volumetricWeight; + decimal chargeableWeight; + try + { + (volumetricWeight, chargeableWeight) = await CalculateChargeableAsync(product, weightRow); + } + catch (InvalidOperationException ex) + { + throw new BusinessException($"计费重计算失败:{ex.Message}"); + } + + var countryCode = string.IsNullOrWhiteSpace(pickup.SenderCountryCode) + ? "CN" + : pickup.SenderCountryCode.Trim().ToUpperInvariant(); + var priceSnapshot = await BuildSellPriceSnapshotAsync(product); + var zoneKeys = ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot( + priceSnapshot, + countryCode, + pickup.SenderProvince, + pickup.SenderCity, + pickup.SenderZipCode); + if (zoneKeys == null || zoneKeys.Count == 0) + { + throw new BusinessException("卖价分区未匹配,请检查揽收地址与产品价格分区"); + } + + var warehouse = await _warehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == pickup.WarehouseId); + var warehouseName = warehouse?.WarehouseName; + var priceVersion = DateTime.Now.ToString("yyyyMMddHHmmss"); + var priceEffectiveTime = DateTime.Now; + var cargoAttribute = CargoAttributeCodes.General; + var conditionContext = OrderRuleEvaluationContext.FromOrderOnly(new Order + { + ProductCode = product.ProductCode, + ConsigneeCountry = countryCode, + CargoAttribute = cargoAttribute, + WarehouseId = pickup.WarehouseId, + CustomerId = pickup.CustomerId + }); + + var receivableLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines( + priceSnapshot, + priceVersion, + priceEffectiveTime, + chargeableWeight, + zoneKeys, + cargoAttribute, + priceTierKey: null, + pickup.WarehouseId, + warehouseName, + conditionContext); + + if (receivableLines.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(priceSnapshot)) + { + var detail = ParcelBillingCalculator.ExplainQuotePriceMiss( + priceSnapshot, + chargeableWeight, + zoneKeys, + cargoAttribute, + null, + pickup.WarehouseId, + warehouseName); + throw new BusinessException($"卖价未匹配到有效价格:{detail}"); + } + + var payable = await CalculatePayableAsync( + product, + pickup, + zoneKeys, + chargeableWeight, + cargoAttribute, + warehouseName, + priceVersion, + priceEffectiveTime, + conditionContext); + + var receivableFees = receivableLines.Select(x => ToFeeLine(x.Fee)).ToList(); + var payableFees = payable.Select(ToFeeLine).ToList(); + var receivableTotal = receivableFees.Sum(x => x.Amount); + var payableTotal = payableFees.Sum(x => x.Amount); + var freight = receivableFees.FirstOrDefault()?.Amount ?? receivableTotal; + var other = Math.Max(0, receivableTotal - freight); + + pickup.TotalActualWeight = input.ActualWeight; + pickup.VolumetricWeight = volumetricWeight; + pickup.ChargeableWeight = chargeableWeight; + pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes); + pickup.CarrierFreightWeight = input.FreightWeight; + pickup.CarrierQuotedFreightAmount = input.TotalFreightAmount; + pickup.CarrierQuotedReceivableAmount = input.ReceivableAmount; + pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput + { + ServiceMode = input.ServiceMode, + PayMode = input.PayMode, + ValueInfoList = input.ValueInfoList ?? [] + }); + pickup.ReceivableFeeJson = OmsDocHelper.SerializeJson(receivableFees); + pickup.PayableFeeJson = OmsDocHelper.SerializeJson(payableFees); + pickup.FreightAmount = freight; + pickup.OtherFeeAmount = other; + pickup.ReceivableAmount = receivableTotal; + pickup.CarrierPayableAmount = payableTotal; + pickup.FeePushedAt = DateTime.Now; + pickup.UpdateTime = DateTime.Now; + await _pickupOrderRepository.UpdateAsync(pickup); + + return new PickupOpenApiPushFeeResult + { + PickupNo = pickup.PickupNo, + ActualWeight = input.ActualWeight, + ChargeableWeight = chargeableWeight, + VolumetricWeight = volumetricWeight, + ReceivableAmount = receivableTotal, + PayableAmount = payableTotal, + CarrierQuotedFreightAmount = input.TotalFreightAmount + }; + } + + private async Task EnsureAppKeyAsync(string appKey, PickupOrder pickup) + { + var config = await _otherConfigRepository.GetByConfigCodeAsync(SystemOtherConfigCodes.PickupOpenApi); + if (config != null && config.Enabled && !string.IsNullOrWhiteSpace(config.AppKey)) + { + if (!string.Equals(config.AppKey.Trim(), appKey, StringComparison.Ordinal)) + { + throw new BusinessException("appKey 不正确"); + } + + return; + } + + var expected = await ResolvePickupAppKeyAsync(pickup); + if (string.IsNullOrWhiteSpace(expected) + || !string.Equals(expected, appKey, StringComparison.Ordinal)) + { + throw new BusinessException("appKey 不正确"); + } + } + + private async Task ResolvePickupAppKeyAsync(PickupOrder pickup) + { + if (string.IsNullOrWhiteSpace(pickup.CarrierService)) + { + return null; + } + + var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim()); + if (product == null) + { + return null; + } + + var providers = await _salesProductRepository.GetAllocationProvidersAsync(product.Id); + var channelId = providers + .Where(x => x.ServiceChannelId is > 0) + .OrderBy(x => x.SerialNumber) + .Select(x => x.ServiceChannelId!.Value) + .FirstOrDefault(); + if (channelId <= 0) + { + return null; + } + + var channel = await _serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId); + if (channel?.GroupId is not > 0) + { + return null; + } + + var group = await _serviceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == channel.GroupId.Value); + if (group == null || group.AppId <= 0) + { + return null; + } + + var app = await _itfxMetadataClient.GetAppAsync(group.AppId); + return app?.AppKey?.Trim(); + } + + private async Task<(decimal VolumetricWeight, decimal ChargeableWeight)> CalculateChargeableAsync( + SalesProduct product, + OrderWeight weight) + { + if (product.WeightRuleId is not > 0) + { + throw new BusinessException("未配置计重规则"); + } + + var ruleInfos = await _weightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value); + var (volumetric, raw) = ParcelBillingCalculator.CalculateRawBillableWeight(weight, ruleInfos); + if (product.WeightRoundingId is not > 0) + { + throw new BusinessException("未配置计重进位"); + } + + var ranges = await _weightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value); + var chargeable = ParcelBillingCalculator.ApplyRounding(raw, ranges); + return (volumetric, chargeable); + } + + private async Task BuildSellPriceSnapshotAsync(SalesProduct product) + { + var snapshot = new SalesProductPricePublishSnapshotDto + { + SalesProductId = product.Id, + Prices = (await GetSellPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList() + }; + await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, _kpiAreaRepository); + return snapshot; + } + + private async Task> GetSellPriceOutputsAsync(int salesProductId) + { + var prices = await _salesProductRepository.GetPricesAsync(salesProductId); + var priceIds = prices.Select(x => x.Id).ToArray(); + var factorMap = (await _salesProductRepository.GetPriceFactorsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList()); + var detailMap = (await _salesProductRepository.GetPriceDetailsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()); + + return prices.Select(price => new SalesProductPriceListOutput + { + Id = price.Id, + SalesProductId = price.SalesProductId, + SerialNumber = price.SerialNumber, + Status = (byte)price.Status, + PriceName = price.PriceName, + CostItem = price.CostItem, + CostUnit = price.CostUnit, + Currency = price.Currency, + ChargingCondition = price.ChargingCondition, + Factors = factorMap.TryGetValue(price.Id, out var factors) + ? factors.Select(f => new ChannelPriceFactorOutput + { + Id = f.Id, + PriceId = f.PriceId, + SerialNumber = f.SerialNumber, + Factor = f.Factor, + FactorValues = OmsDocHelper.DeserializeJson>(f.FactorValues) ?? [] + }).ToList() + : [], + Details = detailMap.TryGetValue(price.Id, out var details) + ? details.Select(d => new ChannelPriceDetailOutput + { + Id = d.Id, + PriceId = d.PriceId, + FactorValuesJson = OmsDocHelper.DeserializeJsonObject(d.FactorValuesJson), + CalculationFormula = d.CalculationFormula, + CalculatedValue = d.CalculatedValue, + CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(d.CustomerGroupValues) + }).ToList() + : [] + }).ToList(); + } + + private async Task> CalculatePayableAsync( + SalesProduct product, + PickupOrder pickup, + IReadOnlyList zoneKeys, + decimal chargeableWeight, + string cargoAttribute, + string? warehouseName, + string priceVersion, + DateTime priceEffectiveTime, + OrderRuleEvaluationContext conditionContext) + { + var providers = await _salesProductRepository.GetAllocationProvidersAsync(product.Id); + var channelIds = providers.Where(x => x.ServiceChannelId is > 0).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray(); + var channels = channelIds.Length == 0 + ? [] + : await _serviceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id)); + var channelNameMap = channels.ToDictionary(x => x.Id, x => x.ChannelName); + var snapshots = providers.Select(provider => new SalesProductAllocationProviderSnapshotDto + { + Id = provider.Id, + SalesProductId = provider.SalesProductId, + SerialNumber = provider.SerialNumber, + ProviderName = provider.ProviderName, + ServiceChannelId = provider.ServiceChannelId, + ServiceChannelName = provider.ServiceChannelId is > 0 + && channelNameMap.TryGetValue(provider.ServiceChannelId.Value, out var name) + ? name + : null, + Weight = provider.Weight, + CustomerIds = OmsDocHelper.DeserializeJson>(provider.CustomerIds) ?? [], + MatchCondition = provider.MatchCondition + }).ToList(); + + var filtered = AllocationResolver.FilterProvidersDetailed(snapshots, pickup.CustomerId, conditionContext).Passed; + var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0); + if (provider?.ServiceChannelId is not > 0) + { + throw new BusinessException("未配置可用排货服务渠道,无法计算应付"); + } + + var channelPrices = await GetChannelPriceOutputsAsync(provider.ServiceChannelId.Value); + if (channelPrices.Count == 0) + { + throw new BusinessException($"服务渠道 {provider.ServiceChannelId} 无买价"); + } + + var payable = ParcelBillingCalculator.CalculatePayableFees( + channelPrices, + priceVersion, + priceEffectiveTime, + chargeableWeight, + zoneKeys, + cargoAttribute, + OrderFeeChargingStageCodes.Forecast, + pickup.WarehouseId, + warehouseName, + conditionContext); + + if (payable.Count == 0) + { + throw new BusinessException($"服务渠道 {provider.ServiceChannelId} 买价未匹配"); + } + + return payable; + } + + private async Task> GetChannelPriceOutputsAsync(int serviceChannelId) + { + var prices = await _serviceChannelRepository.GetPricesAsync(serviceChannelId); + var priceIds = prices.Select(x => x.Id).ToArray(); + var factorMap = (await _serviceChannelRepository.GetPriceFactorsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList()); + var detailMap = (await _serviceChannelRepository.GetPriceDetailsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()); + + return prices.Select(price => new ServiceChannelPriceListOutput + { + Id = price.Id, + ServiceChannelId = price.ServiceChannelId, + SerialNumber = price.SerialNumber, + Status = (byte)price.Status, + PriceName = price.PriceName, + CostItem = price.CostItem, + CostUnit = price.CostUnit, + Currency = price.Currency, + ChargingCondition = price.ChargingCondition, + Factors = factorMap.TryGetValue(price.Id, out var factors) + ? factors.Select(f => new ChannelPriceFactorOutput + { + Id = f.Id, + PriceId = f.PriceId, + SerialNumber = f.SerialNumber, + Factor = f.Factor, + FactorValues = OmsDocHelper.DeserializeJson>(f.FactorValues) ?? [] + }).ToList() + : [], + Details = detailMap.TryGetValue(price.Id, out var details) + ? details.Select(d => new ChannelPriceDetailOutput + { + Id = d.Id, + PriceId = d.PriceId, + FactorValuesJson = OmsDocHelper.DeserializeJsonObject(d.FactorValuesJson), + CalculationFormula = d.CalculationFormula, + CalculatedValue = d.CalculatedValue, + CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(d.CustomerGroupValues) + }).ToList() + : [] + }).ToList(); + } + + private static List NormalizeSizes(List? list) + { + return (list ?? []) + .Select(x => new PickupPackageSizeOutput + { + Length = x.Length, + Width = x.Width, + Height = x.Height, + Count = x.Count <= 0 ? 1 : x.Count + }) + .Where(x => x.Length > 0 && x.Width > 0 && x.Height > 0) + .ToList(); + } + + private static PickupFeeLineOutput ToFeeLine(OrderFee fee) => + new() + { + CostItem = fee.CostItem, + Amount = fee.Amount, + Currency = fee.Currency ?? string.Empty + }; +} diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs index 9020543..4ee5162 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs @@ -1156,6 +1156,15 @@ public class PickupOrderService : ApplicationService ParcelCount = entity.ParcelCount, TotalActualWeight = entity.TotalActualWeight, ChargeableWeight = entity.ChargeableWeight, + VolumetricWeight = entity.VolumetricWeight, + PackageSizes = OmsDocHelper.DeserializeJson>(entity.PackageSizesJson) ?? [], + CarrierFreightWeight = entity.CarrierFreightWeight, + CarrierQuotedFreightAmount = entity.CarrierQuotedFreightAmount, + CarrierQuotedReceivableAmount = entity.CarrierQuotedReceivableAmount, + CarrierQuote = OmsDocHelper.DeserializeJson(entity.CarrierQuoteJson), + ReceivableFees = OmsDocHelper.DeserializeJson>(entity.ReceivableFeeJson) ?? [], + PayableFees = OmsDocHelper.DeserializeJson>(entity.PayableFeeJson) ?? [], + FeePushedAt = entity.FeePushedAt, Currency = entity.Currency, FreightAmount = entity.FreightAmount, OtherFeeAmount = entity.OtherFeeAmount, diff --git a/src/Oms.Application/Services/System/SystemOtherConfigService.cs b/src/Oms.Application/Services/System/SystemOtherConfigService.cs index 2747909..c25a817 100644 --- a/src/Oms.Application/Services/System/SystemOtherConfigService.cs +++ b/src/Oms.Application/Services/System/SystemOtherConfigService.cs @@ -188,6 +188,13 @@ public class SystemOtherConfigService : ApplicationService throw new BusinessException("API 中心应用 AppSecret 不能为空"); } } + else if (string.Equals(configCode, SystemOtherConfigCodes.PickupOpenApi, StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(input.AppKey)) + { + throw new BusinessException("揽收开放接口 AppKey 不能为空"); + } + } else if (string.Equals(configCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(input.AppKey)) diff --git a/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs b/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs index b8de6ce..96acab6 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs @@ -105,6 +105,54 @@ public class PickupOrder : OmsAuditedAggregateRoot [SugarColumn(DecimalDigits = 3, IsNullable = true)] public decimal? ChargeableWeight { get; set; } + [SugarColumn(DecimalDigits = 3, IsNullable = true)] + public decimal? VolumetricWeight { get; set; } + + /// + /// 承运回传尺寸 JSON(goodsSizeList)。 + /// + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? PackageSizesJson { get; set; } + + /// + /// 承运计费重(仅对照,不算账)。 + /// + [SugarColumn(DecimalDigits = 3, IsNullable = true)] + public decimal? CarrierFreightWeight { get; set; } + + /// + /// 承运报价运费合计(仅对照)。 + /// + [SugarColumn(DecimalDigits = 2, IsNullable = true)] + public decimal? CarrierQuotedFreightAmount { get; set; } + + /// + /// 承运报文应收(仅对照,不是客户应收)。 + /// + [SugarColumn(DecimalDigits = 2, IsNullable = true)] + public decimal? CarrierQuotedReceivableAmount { get; set; } + + /// + /// 承运报价原文(附加费、服务方式等)。 + /// + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? CarrierQuoteJson { get; set; } + + /// + /// 系统卖价费用明细 JSON。 + /// + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? ReceivableFeeJson { get; set; } + + /// + /// 系统买价费用明细 JSON。 + /// + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? PayableFeeJson { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? FeePushedAt { get; set; } + [SugarColumn(Length = 8)] public string Currency { get; set; } = "CNY"; diff --git a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs index b11706e..81ad7c1 100644 --- a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs +++ b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs @@ -50,8 +50,13 @@ public static class SystemOtherConfigCodes /// public const string LabelDefaultTemplate = "label_default_template"; -/// -/// API 中心(接口通 / itfx)网关:BaseUrl=itfx 根地址,AppKey/AppSecret=默认应用凭证,TimeoutSeconds=超时。 -/// -public const string ApiCenter = "api_center"; + /// + /// API 中心(接口通 / itfx)网关:BaseUrl=itfx 根地址,AppKey/AppSecret=默认应用凭证,TimeoutSeconds=超时。 + /// + public const string ApiCenter = "api_center"; + + /// + /// 国内揽收服务商推送件重尺/费用:仅校验 AppKey(暂不验签)。 + /// + public const string PickupOpenApi = "pickup_open_api"; } -- 2.45.2 From 955a3c8c6d420de3b2a93f1719b3946723632307 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 29 Sep 2026 19:56:40 +0800 Subject: [PATCH 12/41] =?UTF-8?q?fix:=20=E6=8F=BD=E6=94=B6=E6=8E=A8?= =?UTF-8?q?=E8=B4=B9=E6=8C=89=E6=89=BF=E8=BF=90=E8=BF=90=E5=8D=95=E5=8F=B7?= =?UTF-8?q?=20CarrierWaybillNo=20=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs | 3 +++ .../Services/Orders/PickupOrderFeePushService.cs | 10 +++++----- .../Repositories/IPickupOrderRepository.cs | 2 ++ src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs | 6 ++++++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs index b046a71..8828139 100644 --- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs +++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs @@ -388,6 +388,9 @@ public class PickupOpenApiPushFeeResult public class PickupOpenApiPushFeeInput { + /// + /// 承运运单号,对应库字段 CarrierWaybillNo。 + /// public string? WaybillNumber { get; set; } public decimal ActualWeight { get; set; } diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs index 73dbafa..b1442c0 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs @@ -70,10 +70,10 @@ public class PickupOrderFeePushService : IScopedDependency throw new BusinessException("请求体不能为空"); } - var pickupNo = (input.WaybillNumber ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(pickupNo)) + var carrierWaybillNo = (input.WaybillNumber ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(carrierWaybillNo)) { - throw new BusinessException("waybillNumber 不能为空"); + throw new BusinessException("waybillNumber(承运运单号 CarrierWaybillNo)不能为空"); } if (input.ActualWeight <= 0) @@ -87,8 +87,8 @@ public class PickupOrderFeePushService : IScopedDependency throw new BusinessException("goodsSizeList 不能为空"); } - var pickup = await _pickupOrderRepository.GetByPickupNoAsync(pickupNo) - ?? throw new BusinessException("揽收单不存在"); + var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo) + ?? throw new BusinessException($"揽收单不存在:承运运单号 {carrierWaybillNo}"); await EnsureAppKeyAsync(key, pickup); diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs index 55b9cc8..92ef693 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs @@ -7,6 +7,8 @@ public interface IPickupOrderRepository : IRepository { Task GetByPickupNoAsync(string pickupNo, CancellationToken cancellationToken = default); + Task GetByCarrierWaybillNoAsync(string carrierWaybillNo, CancellationToken cancellationToken = default); + Task GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default); Task GetWorkplaceCountsAsync( diff --git a/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs b/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs index a898e0b..c9a19ca 100644 --- a/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs @@ -14,6 +14,12 @@ public class PickupOrderRepository : OmsSqlSugarRepository, IPickup return FirstOrDefaultAsync(x => x.PickupNo == no, cancellationToken); } + public Task GetByCarrierWaybillNoAsync(string carrierWaybillNo, CancellationToken cancellationToken = default) + { + var no = carrierWaybillNo.Trim(); + return FirstOrDefaultAsync(x => x.CarrierWaybillNo == no, cancellationToken); + } + public Task GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default) { return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken); -- 2.45.2 From 2688058cb3e8f05305069822fa5ea1edcfb836ac Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 30 Sep 2026 10:19:38 +0800 Subject: [PATCH 13/41] =?UTF-8?q?feat:=20=E5=AE=A2=E6=88=B7=20OpenAPI=20?= =?UTF-8?q?=E6=9C=BA=E6=9C=BA=E5=87=AD=E8=AF=81=E4=B8=8E=20whoami=20?= =?UTF-8?q?=E6=8E=A2=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一主客户一对 AppKey/AppSecret,支持门户自助与后台代管启停重置,供应商回调路径不动。 Co-authored-by: Cursor --- sql/migrate_customer_openapi_credential.sql | 41 +++ .../CustomerOpenApiCredentialDtos.cs | 66 +++++ .../CustomerOpenApiCredentialService.cs | 255 ++++++++++++++++++ .../Customers/CustomerOpenApiService.cs | 63 +++++ .../Shared/CustomerOpenApiAuthHelper.cs | 82 ++++++ .../Entities/CustomerOpenApiCredential.cs | 30 +++ .../ICustomerOpenApiCredentialRepository.cs | 15 ++ .../CustomerOpenApiCredentialRepository.cs | 26 ++ 8 files changed, 578 insertions(+) create mode 100644 sql/migrate_customer_openapi_credential.sql create mode 100644 src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs create mode 100644 src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs create mode 100644 src/Oms.Application/Services/Customers/CustomerOpenApiService.cs create mode 100644 src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs create mode 100644 src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs create mode 100644 src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs create mode 100644 src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs diff --git a/sql/migrate_customer_openapi_credential.sql b/sql/migrate_customer_openapi_credential.sql new file mode 100644 index 0000000..da9e138 --- /dev/null +++ b/sql/migrate_customer_openapi_credential.sql @@ -0,0 +1,41 @@ +-- 客户 OpenAPI 机机凭证(一客户一对 AppKey/AppSecret,独立于 itfx) +-- 可重复执行。 +-- 目标表:t_customer_openapi_credential + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_customer_openapi_credential' +); + +SET @sql := IF( + @exists = 0, + 'CREATE TABLE `t_customer_openapi_credential` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `CustomerId` INT NOT NULL COMMENT ''归属主客户 t_customer.Id'', + `AppKey` VARCHAR(64) NOT NULL COMMENT ''开放接口 AppKey'', + `AppSecret` VARCHAR(128) NOT NULL COMMENT ''开放接口 AppSecret(明文,创建/重置时返回)'', + `Enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:0-否,1-是'', + `LastUsedAt` DATETIME NULL COMMENT ''最近鉴权成功时间'', + `LastUsedIp` VARCHAR(64) NULL COMMENT ''最近鉴权成功 IP'', + `Remark` VARCHAR(512) NULL COMMENT ''备注'', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', + `UpdateUserId` INT NULL COMMENT ''更新人'', + `UpdateTime` DATETIME NULL COMMENT ''更新时间'', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_coc_customer` (`CustomerId`), + UNIQUE KEY `uk_coc_app_key` (`AppKey`), + KEY `idx_coc_enabled` (`Enabled`, `IsDeleted`) +) COMMENT=''客户 OpenAPI 机机凭证''', + 'SELECT ''t_customer_openapi_credential already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs new file mode 100644 index 0000000..e9b8d70 --- /dev/null +++ b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs @@ -0,0 +1,66 @@ +namespace Oms.Application.Dtos.Customers; + +public class CustomerOpenApiCredentialOutput +{ + public int Id { get; set; } + + public int CustomerId { get; set; } + + public string AppKey { get; set; } = string.Empty; + + /// 默认掩码;仅 ensure(新建)/ reset-secret 返回完整明文。 + public string AppSecret { get; set; } = string.Empty; + + public bool Enabled { get; set; } + + public bool HasCredential { get; set; } + + /// 本次响应是否包含完整 AppSecret(前端可提示用户复制保存)。 + public bool SecretRevealed { get; set; } + + public DateTime? LastUsedAt { get; set; } + + public string? LastUsedIp { get; set; } + + public DateTime? CreateTime { get; set; } + + public DateTime? UpdateTime { get; set; } +} + +public class CustomerOpenApiCredentialAdminInput +{ + public int CustomerId { get; set; } +} + +public class CustomerOpenApiCredentialSetEnabledInput +{ + public bool Enabled { get; set; } +} + +public class CustomerOpenApiCredentialAdminSetEnabledInput : CustomerOpenApiCredentialAdminInput +{ + public bool Enabled { get; set; } +} + +public class CustomerOpenApiWhoAmIInput +{ + public string? AppSecret { get; set; } +} + +public class CustomerOpenApiWhoAmIResult +{ + public int CustomerId { get; set; } + + public string CustomerCode { get; set; } = string.Empty; + + public string CustomerName { get; set; } = string.Empty; +} + +public class CustomerOpenApiEnvelope +{ + public string Code { get; set; } = "200"; + + public string Msg { get; set; } = "成功"; + + public CustomerOpenApiWhoAmIResult? Data { get; set; } +} diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs new file mode 100644 index 0000000..1381ab6 --- /dev/null +++ b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs @@ -0,0 +1,255 @@ +using System.Security.Cryptography; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Customers; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.CustomersAggregate.Repositories; + +namespace Oms.Application.Services.Customers; + +/// +/// 客户 OpenAPI 凭证管理(门户当前用户 + 后台代管)。 +/// +[ApiExplorerSettings(GroupName = "OMS-Customer")] +public class CustomerOpenApiCredentialService : ApplicationService +{ + private const string SecretMask = "********"; + + private ICustomerRepository CustomerRepository => + LazyServiceProvider.GetRequiredService(); + + private ICustomerOpenApiCredentialRepository CredentialRepository => + LazyServiceProvider.GetRequiredService(); + + /// 当前登录主客户查看凭证(Secret 掩码)。 + [HttpGet("/customer/openapi-credential")] + public async Task GetMineAsync() + { + var customer = await GetCurrentMainCustomerOrThrowAsync(); + return await MapGetAsync(customer.Id); + } + + /// 当前登录主客户:无则生成并返回完整 Secret;已有则掩码返回。 + [HttpPost("/customer/openapi-credential/ensure")] + public async Task EnsureMineAsync() + { + var customer = await GetCurrentMainCustomerOrThrowAsync(); + return await EnsureAsync(customer.Id); + } + + /// 当前登录主客户重置 Secret。 + [HttpPost("/customer/openapi-credential/reset-secret")] + public async Task ResetMineSecretAsync() + { + var customer = await GetCurrentMainCustomerOrThrowAsync(); + return await ResetSecretAsync(customer.Id); + } + + /// 当前登录主客户启停。 + [HttpPost("/customer/openapi-credential/set-enabled")] + public async Task SetMineEnabledAsync( + [FromBody] CustomerOpenApiCredentialSetEnabledInput input) + { + var customer = await GetCurrentMainCustomerOrThrowAsync(); + return await SetEnabledAsync(customer.Id, input.Enabled); + } + + /// 后台按客户查看凭证。 + [HttpGet("/customer/openapi-credential/admin/get")] + public async Task AdminGetAsync([FromQuery] int customerId) + { + EnsureAdminSession(); + await EnsureMainCustomerAsync(customerId); + return await MapGetAsync(customerId); + } + + /// 后台确保生成凭证。 + [HttpPost("/customer/openapi-credential/admin/ensure")] + public async Task AdminEnsureAsync( + [FromBody] CustomerOpenApiCredentialAdminInput input) + { + EnsureAdminSession(); + await EnsureMainCustomerAsync(input.CustomerId); + return await EnsureAsync(input.CustomerId); + } + + /// 后台重置 Secret。 + [HttpPost("/customer/openapi-credential/admin/reset-secret")] + public async Task AdminResetSecretAsync( + [FromBody] CustomerOpenApiCredentialAdminInput input) + { + EnsureAdminSession(); + await EnsureMainCustomerAsync(input.CustomerId); + return await ResetSecretAsync(input.CustomerId); + } + + /// 后台启停。 + [HttpPost("/customer/openapi-credential/admin/set-enabled")] + public async Task AdminSetEnabledAsync( + [FromBody] CustomerOpenApiCredentialAdminSetEnabledInput input) + { + EnsureAdminSession(); + await EnsureMainCustomerAsync(input.CustomerId); + return await SetEnabledAsync(input.CustomerId, input.Enabled); + } + + private async Task MapGetAsync(int customerId) + { + var entity = await CredentialRepository.GetByCustomerIdAsync(customerId); + if (entity == null) + { + return new CustomerOpenApiCredentialOutput + { + CustomerId = customerId, + HasCredential = false, + Enabled = false, + AppKey = string.Empty, + AppSecret = string.Empty, + SecretRevealed = false + }; + } + + return MapOutput(entity, revealSecret: false); + } + + private async Task EnsureAsync(int customerId) + { + var existing = await CredentialRepository.GetByCustomerIdAsync(customerId); + if (existing != null) + { + return MapOutput(existing, revealSecret: false); + } + + var entity = new CustomerOpenApiCredential + { + CustomerId = customerId, + AppKey = await GenerateUniqueAppKeyAsync(), + AppSecret = GenerateAppSecret(), + Enabled = true + }; + entity = await CredentialRepository.InsertAsync(entity); + return MapOutput(entity, revealSecret: true); + } + + private async Task ResetSecretAsync(int customerId) + { + var entity = await CredentialRepository.GetByCustomerIdAsync(customerId); + if (entity == null) + { + throw new BusinessException("尚未生成开放接口凭证,请先生成"); + } + + entity.AppSecret = GenerateAppSecret(); + await CredentialRepository.UpdateAsync(entity); + return MapOutput(entity, revealSecret: true); + } + + private async Task SetEnabledAsync(int customerId, bool enabled) + { + var entity = await CredentialRepository.GetByCustomerIdAsync(customerId); + if (entity == null) + { + throw new BusinessException("尚未生成开放接口凭证,请先生成"); + } + + entity.Enabled = enabled; + await CredentialRepository.UpdateAsync(entity); + return MapOutput(entity, revealSecret: false); + } + + private async Task EnsureMainCustomerAsync(int customerId) + { + if (customerId <= 0) + { + throw new BusinessException("客户ID不能为空"); + } + + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId); + if (customer == null) + { + throw new BusinessException("客户不存在"); + } + + if (customer.ParentCustomerId.HasValue) + { + throw new BusinessException("仅主客户可配置开放接口凭证"); + } + } + + private async Task GetCurrentMainCustomerOrThrowAsync() + { + if (!OmsSessionHelper.IsCustomerLogin(CurrentUser)) + { + throw new BusinessException("仅客户门户可调用此接口"); + } + + var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser); + if (!mapId.HasValue || mapId.Value <= 0) + { + throw new BusinessException("客户未登录"); + } + + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == mapId.Value); + if (customer == null) + { + throw new BusinessException("客户不存在"); + } + + if (customer.ParentCustomerId.HasValue) + { + throw new BusinessException("仅主客户可管理开放接口凭证"); + } + + return customer; + } + + private void EnsureAdminSession() + { + if (OmsSessionHelper.IsCustomerLogin(CurrentUser)) + { + throw new BusinessException("无权限"); + } + } + + private async Task GenerateUniqueAppKeyAsync() + { + for (var i = 0; i < 8; i++) + { + var key = $"oms_{Guid.NewGuid():N}"[..20]; + var existed = await CredentialRepository.GetByAppKeyAsync(key); + if (existed == null) + { + return key; + } + } + + throw new BusinessException("生成 AppKey 失败,请重试"); + } + + private static string GenerateAppSecret() + { + var bytes = RandomNumberGenerator.GetBytes(24); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', 'x').Replace('/', 'y'); + } + + private static CustomerOpenApiCredentialOutput MapOutput( + CustomerOpenApiCredential entity, + bool revealSecret) + { + return new CustomerOpenApiCredentialOutput + { + Id = entity.Id, + CustomerId = entity.CustomerId, + AppKey = entity.AppKey, + AppSecret = revealSecret ? entity.AppSecret : SecretMask, + Enabled = entity.Enabled, + HasCredential = true, + SecretRevealed = revealSecret, + LastUsedAt = entity.LastUsedAt, + LastUsedIp = entity.LastUsedIp, + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; + } +} diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs new file mode 100644 index 0000000..3b44e75 --- /dev/null +++ b/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs @@ -0,0 +1,63 @@ +using Fake.Application; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Customers; +using Oms.Application.Shared; + +namespace Oms.Application.Services.Customers; + +/// +/// 客户 OpenAPI 机机入口(AllowAnonymous,AppKey + AppSecret)。 +/// +[ApiExplorerSettings(GroupName = "OMS-Customer")] +public class CustomerOpenApiService : ApplicationService +{ + private CustomerOpenApiAuthHelper AuthHelper => + LazyServiceProvider.GetRequiredService(); + + /// + /// 探针:校验凭证并返回客户身份。 + /// + [AllowAnonymous] + [HttpPost("/openapi/customer/whoami")] + public async Task WhoAmIAsync( + [FromQuery] string? appKey, + [FromHeader(Name = "appSecret")] string? appSecretHeader, + [FromBody] CustomerOpenApiWhoAmIInput? body) + { + try + { + var appSecret = !string.IsNullOrWhiteSpace(appSecretHeader) + ? appSecretHeader + : body?.AppSecret; + var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret); + return new CustomerOpenApiEnvelope + { + Code = "200", + Msg = "成功", + Data = new CustomerOpenApiWhoAmIResult + { + CustomerId = customer.Id, + CustomerCode = customer.CustomerCode, + CustomerName = customer.CustomerName + } + }; + } + catch (BusinessException ex) + { + return Fail(ex.Message); + } + catch (Exception ex) + { + return Fail(string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message, "500"); + } + } + + private static CustomerOpenApiEnvelope Fail(string msg, string code = "401") => + new() + { + Code = code, + Msg = msg, + Data = null + }; +} diff --git a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs new file mode 100644 index 0000000..94e7458 --- /dev/null +++ b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs @@ -0,0 +1,82 @@ +using Fake.Application; +using Fake.DependencyInjection; +using Microsoft.AspNetCore.Http; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.CustomersAggregate.Enums; +using Oms.Domain.CustomersAggregate.Repositories; + +namespace Oms.Application.Shared; + +/// +/// 客户 OpenAPI 机机鉴权:按 AppKey+AppSecret 解析主客户,供 whoami 及后续业务接口复用。 +/// +public class CustomerOpenApiAuthHelper : IScopedDependency +{ + private readonly ICustomerOpenApiCredentialRepository _credentialRepository; + private readonly ICustomerRepository _customerRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + + public CustomerOpenApiAuthHelper( + ICustomerOpenApiCredentialRepository credentialRepository, + ICustomerRepository customerRepository, + IHttpContextAccessor httpContextAccessor) + { + _credentialRepository = credentialRepository; + _customerRepository = customerRepository; + _httpContextAccessor = httpContextAccessor; + } + + public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveAsync( + string? appKey, + string? appSecret, + CancellationToken cancellationToken = default) + { + var key = appKey?.Trim(); + var secret = appSecret?.Trim(); + if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret)) + { + throw new BusinessException("凭证无效"); + } + + var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken); + if (credential == null + || !credential.Enabled + || !string.Equals(credential.AppSecret, secret, StringComparison.Ordinal)) + { + throw new BusinessException("凭证无效"); + } + + var customer = await _customerRepository.FirstOrDefaultAsync( + x => x.Id == credential.CustomerId, + cancellationToken); + if (customer == null + || customer.ParentCustomerId.HasValue + || customer.Status != CustomerStatus.Normal) + { + throw new BusinessException("凭证无效"); + } + + credential.LastUsedAt = DateTime.Now; + credential.LastUsedIp = ResolveClientIp(); + await _credentialRepository.UpdateAsync(credential); + + return (customer, credential); + } + + private string? ResolveClientIp() + { + var http = _httpContextAccessor.HttpContext; + if (http == null) + { + return null; + } + + var forwarded = http.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(forwarded)) + { + return forwarded.Split(',')[0].Trim(); + } + + return http.Connection.RemoteIpAddress?.ToString(); + } +} diff --git a/src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs b/src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs new file mode 100644 index 0000000..551c61f --- /dev/null +++ b/src/Oms.Domain/CustomersAggregate/Entities/CustomerOpenApiCredential.cs @@ -0,0 +1,30 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.CustomersAggregate.Entities; + +/// +/// 客户 OpenAPI 机机凭证(一主客户一对密钥,独立于 itfx)。 +/// +[SugarTable("t_customer_openapi_credential")] +public class CustomerOpenApiCredential : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + [SugarColumn(Length = 64)] + public string AppKey { get; set; } = string.Empty; + + [SugarColumn(Length = 128)] + public string AppSecret { get; set; } = string.Empty; + + public bool Enabled { get; set; } = true; + + [SugarColumn(IsNullable = true)] + public DateTime? LastUsedAt { get; set; } + + [SugarColumn(Length = 64, IsNullable = true)] + public string? LastUsedIp { get; set; } + + [SugarColumn(Length = 512, IsNullable = true)] + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs new file mode 100644 index 0000000..aefc686 --- /dev/null +++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerOpenApiCredentialRepository.cs @@ -0,0 +1,15 @@ +using Fake.Domain.Repositories; +using Oms.Domain.CustomersAggregate.Entities; + +namespace Oms.Domain.CustomersAggregate.Repositories; + +public interface ICustomerOpenApiCredentialRepository : IRepository +{ + Task GetByCustomerIdAsync( + int customerId, + CancellationToken cancellationToken = default); + + Task GetByAppKeyAsync( + string appKey, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs new file mode 100644 index 0000000..a002c28 --- /dev/null +++ b/src/Oms.SqlSugarCore/Customers/CustomerOpenApiCredentialRepository.cs @@ -0,0 +1,26 @@ +using Fake.DependencyInjection; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Customers; + +public class CustomerOpenApiCredentialRepository + : OmsSqlSugarRepository, + ICustomerOpenApiCredentialRepository, + IScopedDependency +{ + public Task GetByCustomerIdAsync( + int customerId, + CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.CustomerId == customerId, cancellationToken); + } + + public Task GetByAppKeyAsync( + string appKey, + CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.AppKey == appKey, cancellationToken); + } +} -- 2.45.2 From a3b54b6b07e02d0fe03ca2ffd9954a250016c768 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 30 Sep 2026 10:36:24 +0800 Subject: [PATCH 14/41] =?UTF-8?q?feat:=20openApi=20=E5=88=86=E7=BB=84?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=E6=8E=A8=E8=B4=B9=E4=B8=8E=E4=B8=8B=E5=8D=95?= =?UTF-8?q?=EF=BC=8C=E5=8E=BB=E6=8E=89=20OMS=20=E4=BE=A7=20appKey=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swagger openApi 提供 /openapi/pickup-order/push-fee 与 /openapi/parcel/create;删除 method 路由与 whoami。 Co-authored-by: Cursor --- .../CustomerOpenApiCredentialDtos.cs | 23 ----- .../Dtos/Orders/OpenApiParcelCreateInput.cs | 17 ++++ .../Dtos/Orders/PickupOrderDtos.cs | 9 -- .../Customers/CustomerOpenApiService.cs | 63 -------------- .../Services/OpenApi/OpenApiService.cs | 52 +++++++++++ .../Services/Orders/PickupOpenApiService.cs | 76 ---------------- .../Orders/PickupOrderFeePushService.cs | 86 +------------------ .../System/SystemOtherConfigService.cs | 5 +- .../Consts/SystemOtherConfigCodes.cs | 2 +- 9 files changed, 73 insertions(+), 260 deletions(-) create mode 100644 src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs delete mode 100644 src/Oms.Application/Services/Customers/CustomerOpenApiService.cs create mode 100644 src/Oms.Application/Services/OpenApi/OpenApiService.cs delete mode 100644 src/Oms.Application/Services/Orders/PickupOpenApiService.cs diff --git a/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs index e9b8d70..e5b11c3 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerOpenApiCredentialDtos.cs @@ -41,26 +41,3 @@ public class CustomerOpenApiCredentialAdminSetEnabledInput : CustomerOpenApiCred { public bool Enabled { get; set; } } - -public class CustomerOpenApiWhoAmIInput -{ - public string? AppSecret { get; set; } -} - -public class CustomerOpenApiWhoAmIResult -{ - public int CustomerId { get; set; } - - public string CustomerCode { get; set; } = string.Empty; - - public string CustomerName { get; set; } = string.Empty; -} - -public class CustomerOpenApiEnvelope -{ - public string Code { get; set; } = "200"; - - public string Msg { get; set; } = "成功"; - - public CustomerOpenApiWhoAmIResult? Data { get; set; } -} diff --git a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs new file mode 100644 index 0000000..c48882c --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs @@ -0,0 +1,17 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 开放接口创建订单包裹:在标准下单入参上增加 AppKey/AppSecret(Body,非自定义 Header)。 +/// +public class OpenApiParcelCreateInput : OrderParcelCreateInput +{ + /// + /// 客户开放接口 AppKey。 + /// + public string? AppKey { get; set; } + + /// + /// 客户开放接口 AppSecret。 + /// + public string? AppSecret { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs index 8828139..ab046bc 100644 --- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs +++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs @@ -360,15 +360,6 @@ public class PickupCarrierValueFeeOutput public decimal? FeeAmount { get; set; } } -public class PickupOpenApiEnvelope -{ - public string Code { get; set; } = "200"; - - public string Msg { get; set; } = "成功"; - - public PickupOpenApiPushFeeResult? Data { get; set; } -} - public class PickupOpenApiPushFeeResult { public string PickupNo { get; set; } = string.Empty; diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs deleted file mode 100644 index 3b44e75..0000000 --- a/src/Oms.Application/Services/Customers/CustomerOpenApiService.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Fake.Application; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Oms.Application.Dtos.Customers; -using Oms.Application.Shared; - -namespace Oms.Application.Services.Customers; - -/// -/// 客户 OpenAPI 机机入口(AllowAnonymous,AppKey + AppSecret)。 -/// -[ApiExplorerSettings(GroupName = "OMS-Customer")] -public class CustomerOpenApiService : ApplicationService -{ - private CustomerOpenApiAuthHelper AuthHelper => - LazyServiceProvider.GetRequiredService(); - - /// - /// 探针:校验凭证并返回客户身份。 - /// - [AllowAnonymous] - [HttpPost("/openapi/customer/whoami")] - public async Task WhoAmIAsync( - [FromQuery] string? appKey, - [FromHeader(Name = "appSecret")] string? appSecretHeader, - [FromBody] CustomerOpenApiWhoAmIInput? body) - { - try - { - var appSecret = !string.IsNullOrWhiteSpace(appSecretHeader) - ? appSecretHeader - : body?.AppSecret; - var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret); - return new CustomerOpenApiEnvelope - { - Code = "200", - Msg = "成功", - Data = new CustomerOpenApiWhoAmIResult - { - CustomerId = customer.Id, - CustomerCode = customer.CustomerCode, - CustomerName = customer.CustomerName - } - }; - } - catch (BusinessException ex) - { - return Fail(ex.Message); - } - catch (Exception ex) - { - return Fail(string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message, "500"); - } - } - - private static CustomerOpenApiEnvelope Fail(string msg, string code = "401") => - new() - { - Code = code, - Msg = msg, - Data = null - }; -} diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs new file mode 100644 index 0000000..189b4ef --- /dev/null +++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs @@ -0,0 +1,52 @@ +using Fake.Application; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Orders; +using Oms.Application.Shared; + +namespace Oms.Application.Services.OpenApi; + +/// +/// 对外开放 API(Swagger 文档分组:openApi)。 +/// 客户机机:AppKey/AppSecret 放在 JSON Body;供应商推费由上游 itfx 鉴权,OMS 不再验 key。 +/// +[ApiExplorerSettings(GroupName = "openApi")] +public class OpenApiService : ApplicationService +{ + private CustomerOpenApiAuthHelper AuthHelper => + LazyServiceProvider.GetRequiredService(); + + private PickupOrderFeePushService FeePush => + LazyServiceProvider.GetRequiredService(); + + private OrderService OrderService => + LazyServiceProvider.GetRequiredService(); + + /// + /// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需 AppKey)。 + /// + [AllowAnonymous] + [HttpPost("/openapi/pickup-order/push-fee")] + public Task PushFeeAsync([FromBody] PickupOpenApiPushFeeInput input) => + FeePush.PushFeeAsync(input); + + /// + /// 创建订单包裹(客户机机:Body 传 AppKey、AppSecret)。 + /// + [AllowAnonymous] + [HttpPost("/openapi/parcel/create")] + public async Task CreateParcelAsync([FromBody] OpenApiParcelCreateInput input) + { + ArgumentNullException.ThrowIfNull(input); + + var (customer, _) = await AuthHelper.ResolveAsync(input.AppKey, input.AppSecret); + if (input.CustomerId is > 0 && input.CustomerId.Value != customer.Id) + { + throw new BusinessException("凭证无效"); + } + + input.CustomerId = customer.Id; + return await OrderService.CreateParcelAsync(input); + } +} diff --git a/src/Oms.Application/Services/Orders/PickupOpenApiService.cs b/src/Oms.Application/Services/Orders/PickupOpenApiService.cs deleted file mode 100644 index a5a2dd4..0000000 --- a/src/Oms.Application/Services/Orders/PickupOpenApiService.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Fake.Application; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Oms.Application.Dtos.Orders; - -namespace Oms.Application.Services.Orders; - -/// -/// 国内揽收开放接口(承运商推送件重尺)。 -/// -[ApiExplorerSettings(GroupName = "OMS-Order")] -public class PickupOpenApiService : ApplicationService -{ - private PickupOrderFeePushService FeePush => - LazyServiceProvider.GetRequiredService(); - - /// - /// 开放平台路由:method=open.api.openCommon.pushFee,仅校验 appKey。 - /// - [AllowAnonymous] - [HttpPost("/openapi")] - public Task InvokeAsync( - [FromQuery] string? appKey, - [FromQuery] string? method, - [FromBody] PickupOpenApiPushFeeInput? body) => - HandlePushFeeAsync(appKey, method, body); - - /// - /// 推送揽收重量/尺寸并按当前产品计算应收应付。 - /// - [AllowAnonymous] - [HttpPost("/openapi/openCommon/pushFee")] - public Task PushFeeAsync( - [FromQuery] string? appKey, - [FromBody] PickupOpenApiPushFeeInput? body) => - HandlePushFeeAsync(appKey, PickupOrderFeePushService.PushFeeMethod, body); - - private async Task HandlePushFeeAsync( - string? appKey, - string? method, - PickupOpenApiPushFeeInput? body) - { - try - { - if (!string.IsNullOrWhiteSpace(method) - && !string.Equals(method.Trim(), PickupOrderFeePushService.PushFeeMethod, StringComparison.OrdinalIgnoreCase)) - { - return Fail("400", $"不支持的 method:{method}"); - } - - var data = await FeePush.PushFeeAsync(appKey, body); - return new PickupOpenApiEnvelope - { - Code = "200", - Msg = "成功", - Data = data - }; - } - catch (BusinessException ex) - { - return Fail("400", ex.Message); - } - catch (Exception ex) - { - return Fail("500", string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message); - } - } - - private static PickupOpenApiEnvelope Fail(string code, string msg) => - new() - { - Code = code, - Msg = msg, - Data = null - }; -} diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs index b1442c0..37fcafc 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs @@ -2,7 +2,6 @@ using Fake.Application; using Fake.DependencyInjection; using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; -using Oms.Application.Integrations.Itfx; using Oms.Application.Services.Orders.Issues; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Entities; @@ -10,7 +9,6 @@ using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; -using Oms.Domain.SystemAggregate.Consts; using Oms.Domain.SystemAggregate.Repositories; namespace Oms.Application.Services.Orders; @@ -20,51 +18,34 @@ namespace Oms.Application.Services.Orders; /// public class PickupOrderFeePushService : IScopedDependency { - public const string PushFeeMethod = "open.api.openCommon.pushFee"; - private readonly IPickupOrderRepository _pickupOrderRepository; - private readonly ISystemOtherConfigRepository _otherConfigRepository; private readonly ISalesProductRepository _salesProductRepository; private readonly IServiceChannelRepository _serviceChannelRepository; - private readonly IServiceChannelGroupRepository _serviceChannelGroupRepository; private readonly IWeightRuleRepository _weightRuleRepository; private readonly IWeightRoundingRepository _weightRoundingRepository; private readonly IKpiAreaRepository _kpiAreaRepository; private readonly IWarehouseAddressRepository _warehouseAddressRepository; - private readonly IItfxMetadataClient _itfxMetadataClient; public PickupOrderFeePushService( IPickupOrderRepository pickupOrderRepository, - ISystemOtherConfigRepository otherConfigRepository, ISalesProductRepository salesProductRepository, IServiceChannelRepository serviceChannelRepository, - IServiceChannelGroupRepository serviceChannelGroupRepository, IWeightRuleRepository weightRuleRepository, IWeightRoundingRepository weightRoundingRepository, IKpiAreaRepository kpiAreaRepository, - IWarehouseAddressRepository warehouseAddressRepository, - IItfxMetadataClient itfxMetadataClient) + IWarehouseAddressRepository warehouseAddressRepository) { _pickupOrderRepository = pickupOrderRepository; - _otherConfigRepository = otherConfigRepository; _salesProductRepository = salesProductRepository; _serviceChannelRepository = serviceChannelRepository; - _serviceChannelGroupRepository = serviceChannelGroupRepository; _weightRuleRepository = weightRuleRepository; _weightRoundingRepository = weightRoundingRepository; _kpiAreaRepository = kpiAreaRepository; _warehouseAddressRepository = warehouseAddressRepository; - _itfxMetadataClient = itfxMetadataClient; } - public async Task PushFeeAsync(string? appKey, PickupOpenApiPushFeeInput? input) + public async Task PushFeeAsync(PickupOpenApiPushFeeInput? input) { - var key = (appKey ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(key)) - { - throw new BusinessException("appKey 不能为空"); - } - if (input == null) { throw new BusinessException("请求体不能为空"); @@ -90,8 +71,6 @@ public class PickupOrderFeePushService : IScopedDependency var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo) ?? throw new BusinessException($"揽收单不存在:承运运单号 {carrierWaybillNo}"); - await EnsureAppKeyAsync(key, pickup); - if (pickup.PayStatus == PickupPayStatusCodes.Paid) { throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用"); @@ -240,67 +219,6 @@ public class PickupOrderFeePushService : IScopedDependency }; } - private async Task EnsureAppKeyAsync(string appKey, PickupOrder pickup) - { - var config = await _otherConfigRepository.GetByConfigCodeAsync(SystemOtherConfigCodes.PickupOpenApi); - if (config != null && config.Enabled && !string.IsNullOrWhiteSpace(config.AppKey)) - { - if (!string.Equals(config.AppKey.Trim(), appKey, StringComparison.Ordinal)) - { - throw new BusinessException("appKey 不正确"); - } - - return; - } - - var expected = await ResolvePickupAppKeyAsync(pickup); - if (string.IsNullOrWhiteSpace(expected) - || !string.Equals(expected, appKey, StringComparison.Ordinal)) - { - throw new BusinessException("appKey 不正确"); - } - } - - private async Task ResolvePickupAppKeyAsync(PickupOrder pickup) - { - if (string.IsNullOrWhiteSpace(pickup.CarrierService)) - { - return null; - } - - var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim()); - if (product == null) - { - return null; - } - - var providers = await _salesProductRepository.GetAllocationProvidersAsync(product.Id); - var channelId = providers - .Where(x => x.ServiceChannelId is > 0) - .OrderBy(x => x.SerialNumber) - .Select(x => x.ServiceChannelId!.Value) - .FirstOrDefault(); - if (channelId <= 0) - { - return null; - } - - var channel = await _serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId); - if (channel?.GroupId is not > 0) - { - return null; - } - - var group = await _serviceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == channel.GroupId.Value); - if (group == null || group.AppId <= 0) - { - return null; - } - - var app = await _itfxMetadataClient.GetAppAsync(group.AppId); - return app?.AppKey?.Trim(); - } - private async Task<(decimal VolumetricWeight, decimal ChargeableWeight)> CalculateChargeableAsync( SalesProduct product, OrderWeight weight) diff --git a/src/Oms.Application/Services/System/SystemOtherConfigService.cs b/src/Oms.Application/Services/System/SystemOtherConfigService.cs index c25a817..b213f87 100644 --- a/src/Oms.Application/Services/System/SystemOtherConfigService.cs +++ b/src/Oms.Application/Services/System/SystemOtherConfigService.cs @@ -190,10 +190,7 @@ public class SystemOtherConfigService : ApplicationService } else if (string.Equals(configCode, SystemOtherConfigCodes.PickupOpenApi, StringComparison.OrdinalIgnoreCase)) { - if (string.IsNullOrWhiteSpace(input.AppKey)) - { - throw new BusinessException("揽收开放接口 AppKey 不能为空"); - } + // 鉴权已改由 itfx 上游完成;本配置不再强制 AppKey } else if (string.Equals(configCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs index 81ad7c1..02f0bd1 100644 --- a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs +++ b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs @@ -56,7 +56,7 @@ public static class SystemOtherConfigCodes public const string ApiCenter = "api_center"; /// - /// 国内揽收服务商推送件重尺/费用:仅校验 AppKey(暂不验签)。 + /// 国内揽收服务商推送件重尺/费用(历史配置;鉴权已改由 itfx 上游应用完成,OMS 不再校验 AppKey)。 /// public const string PickupOpenApi = "pickup_open_api"; } -- 2.45.2 From 87f4cebc9b38f1f4d358028ae41ef7d8ffc088ca Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 30 Sep 2026 12:17:50 +0800 Subject: [PATCH 15/41] =?UTF-8?q?feat:=20openApi=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=BF=A1=E5=B0=81=E4=B8=8E=E6=8E=A8=E8=B4=B9=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=EF=BC=8C=E4=B8=8B=E5=8D=95=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E6=94=B9=E8=AF=B7=E6=B1=82=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 推费入参对齐表字段;创建订单凭证走 appKey/appSecret Header,去掉上门揽收与 Body 内 customerId。 Co-authored-by: Cursor --- .../Dtos/OpenApi/OpenApiEnvelope.cs | 29 ++++ .../Dtos/Orders/OpenApiParcelCreateInput.cs | 148 +++++++++++++++++- .../Dtos/Orders/PickupOrderDtos.cs | 37 +++-- .../Services/OpenApi/OpenApiService.cs | 59 +++++-- .../Orders/PickupOrderFeePushService.cs | 30 ++-- .../Shared/CustomerOpenApiAuthHelper.cs | 35 ++++- 6 files changed, 279 insertions(+), 59 deletions(-) create mode 100644 src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs new file mode 100644 index 0000000..4d724f0 --- /dev/null +++ b/src/Oms.Application/Dtos/OpenApi/OpenApiEnvelope.cs @@ -0,0 +1,29 @@ +namespace Oms.Application.Dtos.OpenApi; + +/// +/// 对外开放接口统一响应。 +/// +public class OpenApiEnvelope +{ + public string Code { get; set; } = "200"; + + public string Msg { get; set; } = "success"; + + public T? Data { get; set; } + + public static OpenApiEnvelope Ok(T data) => + new() + { + Code = "200", + Msg = "success", + Data = data + }; + + public static OpenApiEnvelope Fail(string msg, string code = "400") => + new() + { + Code = code, + Msg = msg, + Data = default + }; +} diff --git a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs index c48882c..ccf50e9 100644 --- a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs +++ b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs @@ -1,17 +1,149 @@ +using Oms.Domain.OrdersAggregate.Consts; + namespace Oms.Application.Dtos.Orders; /// -/// 开放接口创建订单包裹:在标准下单入参上增加 AppKey/AppSecret(Body,非自定义 Header)。 +/// 开放接口创建订单包裹入参:与单票字段对齐(不含上门揽收)。 +/// 鉴权凭证放请求头 appKey / appSecret,勿放入 Body。 /// -public class OpenApiParcelCreateInput : OrderParcelCreateInput +public class OpenApiParcelCreateInput { - /// - /// 客户开放接口 AppKey。 - /// - public string? AppKey { get; set; } + /// 请求标识(幂等,可选)。 + public string? RequestId { get; set; } + + /// 客户单号。 + public string? ClientNo { get; set; } + + /// 跟踪号(可选,系统通常忽略由下游回写)。 + public string? TrackNo { get; set; } + + /// 服务单号(可选,不传时系统自动生成)。 + public string? ServiceNo { get; set; } + + /// 产品编码(运输方式)。 + public string? ProductCode { get; set; } + + /// 渠道编码(可选)。 + public string? ChannelCode { get; set; } + + /// 货物属性(字典 cargo_attribute:general / electronics / sensitive)。 + public string? CargoAttribute { get; set; } + + /// 出口报关方式(字典 export_customs_mode)。 + public string? ExportCustomsMode { get; set; } + + /// 出口资质备案 Id(9610 汇总申报时)。 + public int? ExportQualificationId { get; set; } + + /// 电商平台(9610 汇总申报时)。 + public string? PlatformCode { get; set; } + + /// 仓库主键。 + public int? WarehouseId { get; set; } + + /// 长(cm)。 + public decimal? Length { get; set; } + + /// 宽(cm)。 + public decimal? Width { get; set; } + + /// 高(cm)。 + public decimal? Height { get; set; } + + /// 实重(kg)。 + public decimal? ActualWeight { get; set; } + + /// 体积重(kg,可选)。 + public decimal? VolumetricWeight { get; set; } + + /// 计费重(kg,可选)。 + public decimal? ChargeableWeight { get; set; } + + /// 体积(cm³,可选)。 + public decimal? Cubic { get; set; } + + /// 是否 COD。 + public bool IsCod { get; set; } + + /// COD 币种。 + public string? CodCurrency { get; set; } + + /// COD 金额。 + public decimal? CodSum { get; set; } + + /// 申报币种(字典 currency)。 + public string? DeclaredCurrency { get; set; } + + /// 申报总金额(可选,不填可由明细汇总)。 + public decimal? DeclaredSum { get; set; } + + /// 附加服务字典 code 列表。 + public List? AdditionalServices { get; set; } + + /// 是否偏远(可选预传)。 + public byte? IsRemote { get; set; } + + /// 包裹数量。 + public int? Quantity { get; set; } + + /// 收件人。 + public OrderParcelReceiverInput? Receiver { get; set; } + + /// 寄件人。 + public OrderParcelSenderInput? Sender { get; set; } + + /// 申报明细。 + public List Items { get; set; } = []; + + /// 优惠券实例 Id(可选)。 + public int? CouponInstanceId { get; set; } + + /// 仅保存草稿,不预报计费。 + public bool SaveAsDraft { get; set; } /// - /// 客户开放接口 AppSecret。 + /// 映射为内部单票创建入参。开放接口固定自发货(selfShip),客户 Id 由凭证解析后写入。 /// - public string? AppSecret { get; set; } + public OrderParcelCreateInput ToOrderParcelCreateInput(int customerId) => + new() + { + RequestId = RequestId, + ClientNo = ClientNo, + TrackNo = TrackNo, + ServiceNo = ServiceNo, + ProductCode = ProductCode, + ChannelCode = ChannelCode, + CargoAttribute = CargoAttribute, + ExportCustomsMode = ExportCustomsMode, + ExportQualificationId = ExportQualificationId, + PlatformCode = PlatformCode, + WarehouseId = WarehouseId, + CustomerId = customerId, + Length = Length, + Width = Width, + Height = Height, + ActualWeight = ActualWeight, + VolumetricWeight = VolumetricWeight, + ChargeableWeight = ChargeableWeight, + Cubic = Cubic, + IsCod = IsCod, + CodCurrency = CodCurrency, + CodSum = CodSum, + DeclaredCurrency = DeclaredCurrency, + DeclaredSum = DeclaredSum, + AdditionalServices = AdditionalServices, + IsRemote = IsRemote, + Quantity = Quantity, + Receiver = Receiver, + Sender = Sender, + Items = Items ?? [], + CouponInstanceId = CouponInstanceId, + SaveAsDraft = SaveAsDraft, + InboundMethod = OrderInboundMethodCodes.SelfShip, + SenderAddressId = null, + ExpectPickupTime = null, + PickupDirection = null, + CarrierCode = null, + CarrierService = null + }; } diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs index ab046bc..d9fd2fd 100644 --- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs +++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs @@ -344,10 +344,6 @@ public class PickupFeeLineOutput public class PickupCarrierQuoteOutput { - public string? ServiceMode { get; set; } - - public string? PayMode { get; set; } - public List ValueInfoList { get; set; } = []; } @@ -364,7 +360,7 @@ public class PickupOpenApiPushFeeResult { public string PickupNo { get; set; } = string.Empty; - public decimal ActualWeight { get; set; } + public decimal TotalActualWeight { get; set; } public decimal ChargeableWeight { get; set; } @@ -372,31 +368,34 @@ public class PickupOpenApiPushFeeResult public decimal ReceivableAmount { get; set; } - public decimal PayableAmount { get; set; } + public decimal? CarrierPayableAmount { get; set; } public decimal? CarrierQuotedFreightAmount { get; set; } } +/// +/// 承运推送件重尺入参(字段名对齐 t_pickup_order)。 +/// public class PickupOpenApiPushFeeInput { - /// - /// 承运运单号,对应库字段 CarrierWaybillNo。 - /// - public string? WaybillNumber { get; set; } + /// 承运运单号。 + public string? CarrierWaybillNo { get; set; } - public decimal ActualWeight { get; set; } + /// 实重合计。 + public decimal TotalActualWeight { get; set; } - public decimal? FreightWeight { get; set; } + /// 承运计费重(对照)。 + public decimal? CarrierFreightWeight { get; set; } - public decimal? TotalFreightAmount { get; set; } + /// 承运报价运费合计(对照)。 + public decimal? CarrierQuotedFreightAmount { get; set; } - public decimal? ReceivableAmount { get; set; } + /// 承运报文应收(对照)。 + public decimal? CarrierQuotedReceivableAmount { get; set; } + /// 承运附加费明细(写入 CarrierQuoteJson)。 public List? ValueInfoList { get; set; } - public List? GoodsSizeList { get; set; } - - public string? ServiceMode { get; set; } - - public string? PayMode { get; set; } + /// 尺寸明细(写入 PackageSizesJson)。 + public List? PackageSizes { get; set; } } diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs index 189b4ef..2b5c66f 100644 --- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs +++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs @@ -1,6 +1,7 @@ using Fake.Application; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.OpenApi; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; using Oms.Application.Shared; @@ -9,7 +10,8 @@ namespace Oms.Application.Services.OpenApi; /// /// 对外开放 API(Swagger 文档分组:openApi)。 -/// 客户机机:AppKey/AppSecret 放在 JSON Body;供应商推费由上游 itfx 鉴权,OMS 不再验 key。 +/// 统一响应:{ code, msg, data }。 +/// 客户机机鉴权:请求头 appKey + appSecret(勿放 Body,避免落日志)。 /// [ApiExplorerSettings(GroupName = "openApi")] public class OpenApiService : ApplicationService @@ -24,29 +26,58 @@ public class OpenApiService : ApplicationService LazyServiceProvider.GetRequiredService(); /// - /// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需 AppKey)。 + /// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。 /// [AllowAnonymous] [HttpPost("/openapi/pickup-order/push-fee")] - public Task PushFeeAsync([FromBody] PickupOpenApiPushFeeInput input) => - FeePush.PushFeeAsync(input); + public async Task> PushFeeAsync( + [FromBody] PickupOpenApiPushFeeInput input) + { + try + { + var data = await FeePush.PushFeeAsync(input); + return OpenApiEnvelope.Ok(data); + } + catch (BusinessException ex) + { + return OpenApiEnvelope.Fail(ex.Message); + } + catch (Exception ex) + { + return OpenApiEnvelope.Fail( + string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message, + "500"); + } + } /// - /// 创建订单包裹(客户机机:Body 传 AppKey、AppSecret)。 + /// 创建订单包裹(仅自发货)。 + /// 鉴权:Header appKey、appSecret;客户归属强制取自凭证,Body 不可指定 customerId。 /// [AllowAnonymous] [HttpPost("/openapi/parcel/create")] - public async Task CreateParcelAsync([FromBody] OpenApiParcelCreateInput input) + public async Task> CreateParcelAsync( + [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey, + [FromHeader(Name = CustomerOpenApiAuthHelper.AppSecretHeaderName)] string? appSecret, + [FromBody] OpenApiParcelCreateInput input) { - ArgumentNullException.ThrowIfNull(input); - - var (customer, _) = await AuthHelper.ResolveAsync(input.AppKey, input.AppSecret); - if (input.CustomerId is > 0 && input.CustomerId.Value != customer.Id) + try { - throw new BusinessException("凭证无效"); - } + ArgumentNullException.ThrowIfNull(input); - input.CustomerId = customer.Id; - return await OrderService.CreateParcelAsync(input); + var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret); + var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id)); + return OpenApiEnvelope.Ok(data); + } + catch (BusinessException ex) + { + return OpenApiEnvelope.Fail(ex.Message); + } + catch (Exception ex) + { + return OpenApiEnvelope.Fail( + string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message, + "500"); + } } } diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs index 37fcafc..7cb00fd 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs @@ -51,21 +51,21 @@ public class PickupOrderFeePushService : IScopedDependency throw new BusinessException("请求体不能为空"); } - var carrierWaybillNo = (input.WaybillNumber ?? string.Empty).Trim(); + var carrierWaybillNo = (input.CarrierWaybillNo ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(carrierWaybillNo)) { - throw new BusinessException("waybillNumber(承运运单号 CarrierWaybillNo)不能为空"); + throw new BusinessException("carrierWaybillNo(承运运单号)不能为空"); } - if (input.ActualWeight <= 0) + if (input.TotalActualWeight <= 0) { - throw new BusinessException("actualWeight 必须大于 0"); + throw new BusinessException("totalActualWeight 必须大于 0"); } - var sizes = NormalizeSizes(input.GoodsSizeList); + var sizes = NormalizeSizes(input.PackageSizes); if (sizes.Count == 0) { - throw new BusinessException("goodsSizeList 不能为空"); + throw new BusinessException("packageSizes 不能为空"); } var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo) @@ -97,7 +97,7 @@ public class PickupOrderFeePushService : IScopedDependency Length = totalVolume, Width = 1, Height = 1, - ActualWeight = input.ActualWeight, + ActualWeight = input.TotalActualWeight, Cubic = ParcelBillingCalculator.CalculateCubic(totalVolume, 1, 1) }; @@ -184,17 +184,15 @@ public class PickupOrderFeePushService : IScopedDependency var freight = receivableFees.FirstOrDefault()?.Amount ?? receivableTotal; var other = Math.Max(0, receivableTotal - freight); - pickup.TotalActualWeight = input.ActualWeight; + pickup.TotalActualWeight = input.TotalActualWeight; pickup.VolumetricWeight = volumetricWeight; pickup.ChargeableWeight = chargeableWeight; pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes); - pickup.CarrierFreightWeight = input.FreightWeight; - pickup.CarrierQuotedFreightAmount = input.TotalFreightAmount; - pickup.CarrierQuotedReceivableAmount = input.ReceivableAmount; + pickup.CarrierFreightWeight = input.CarrierFreightWeight; + pickup.CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount; + pickup.CarrierQuotedReceivableAmount = input.CarrierQuotedReceivableAmount; pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput { - ServiceMode = input.ServiceMode, - PayMode = input.PayMode, ValueInfoList = input.ValueInfoList ?? [] }); pickup.ReceivableFeeJson = OmsDocHelper.SerializeJson(receivableFees); @@ -210,12 +208,12 @@ public class PickupOrderFeePushService : IScopedDependency return new PickupOpenApiPushFeeResult { PickupNo = pickup.PickupNo, - ActualWeight = input.ActualWeight, + TotalActualWeight = input.TotalActualWeight, ChargeableWeight = chargeableWeight, VolumetricWeight = volumetricWeight, ReceivableAmount = receivableTotal, - PayableAmount = payableTotal, - CarrierQuotedFreightAmount = input.TotalFreightAmount + CarrierPayableAmount = payableTotal, + CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount }; } diff --git a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs index 94e7458..b65658c 100644 --- a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs +++ b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Fake.Application; using Fake.DependencyInjection; using Microsoft.AspNetCore.Http; @@ -8,10 +10,13 @@ using Oms.Domain.CustomersAggregate.Repositories; namespace Oms.Application.Shared; /// -/// 客户 OpenAPI 机机鉴权:按 AppKey+AppSecret 解析主客户,供 whoami 及后续业务接口复用。 +/// 客户 OpenAPI 机机鉴权:按请求头 AppKey + AppSecret 解析主客户。 /// public class CustomerOpenApiAuthHelper : IScopedDependency { + public const string AppKeyHeaderName = "appKey"; + public const string AppSecretHeaderName = "appSecret"; + private readonly ICustomerOpenApiCredentialRepository _credentialRepository; private readonly ICustomerRepository _customerRepository; private readonly IHttpContextAccessor _httpContextAccessor; @@ -26,6 +31,19 @@ public class CustomerOpenApiAuthHelper : IScopedDependency _httpContextAccessor = httpContextAccessor; } + /// + /// 从当前请求头读取 appKey / appSecret 并鉴权。 + /// + public Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveFromHeadersAsync( + CancellationToken cancellationToken = default) + { + var http = _httpContextAccessor.HttpContext + ?? throw new BusinessException("凭证无效"); + var appKey = http.Request.Headers[AppKeyHeaderName].FirstOrDefault(); + var appSecret = http.Request.Headers[AppSecretHeaderName].FirstOrDefault(); + return ResolveAsync(appKey, appSecret, cancellationToken); + } + public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveAsync( string? appKey, string? appSecret, @@ -41,7 +59,7 @@ public class CustomerOpenApiAuthHelper : IScopedDependency var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken); if (credential == null || !credential.Enabled - || !string.Equals(credential.AppSecret, secret, StringComparison.Ordinal)) + || !FixedTimeEqualsUtf8(credential.AppSecret, secret)) { throw new BusinessException("凭证无效"); } @@ -63,6 +81,19 @@ public class CustomerOpenApiAuthHelper : IScopedDependency return (customer, credential); } + private static bool FixedTimeEqualsUtf8(string? expected, string actual) + { + if (string.IsNullOrEmpty(expected)) + { + return false; + } + + var left = Encoding.UTF8.GetBytes(expected); + var right = Encoding.UTF8.GetBytes(actual); + return left.Length == right.Length + && CryptographicOperations.FixedTimeEquals(left, right); + } + private string? ResolveClientIp() { var http = _httpContextAccessor.HttpContext; -- 2.45.2 From 1b6e91b09d0502d6354a05fbbdb77c5da5299404 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 30 Sep 2026 17:33:46 +0800 Subject: [PATCH 16/41] =?UTF-8?q?feat:=20OpenAPI=20=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E6=94=B9=20HMAC=20=E7=AD=BE=E5=90=8D=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84=E4=B8=8B=E5=8D=95=E6=8A=A5?= =?UTF-8?q?=E6=96=87=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/Orders/OpenApiParcelCreateInput.cs | 5 +- .../Services/OpenApi/OpenApiService.cs | 55 +++++++++++-- .../Shared/CustomerOpenApiAuthHelper.cs | 78 ++++++++++++++----- src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 11 +++ 4 files changed, 121 insertions(+), 28 deletions(-) diff --git a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs index ccf50e9..dcfb695 100644 --- a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs +++ b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs @@ -8,9 +8,6 @@ namespace Oms.Application.Dtos.Orders; /// public class OpenApiParcelCreateInput { - /// 请求标识(幂等,可选)。 - public string? RequestId { get; set; } - /// 客户单号。 public string? ClientNo { get; set; } @@ -107,7 +104,7 @@ public class OpenApiParcelCreateInput public OrderParcelCreateInput ToOrderParcelCreateInput(int customerId) => new() { - RequestId = RequestId, + RequestId = null, ClientNo = ClientNo, TrackNo = TrackNo, ServiceNo = ServiceNo, diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs index 2b5c66f..0076fbb 100644 --- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs +++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs @@ -1,5 +1,8 @@ +using System.Text; +using System.Text.Json; using Fake.Application; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.OpenApi; using Oms.Application.Dtos.Orders; @@ -11,11 +14,16 @@ namespace Oms.Application.Services.OpenApi; /// /// 对外开放 API(Swagger 文档分组:openApi)。 /// 统一响应:{ code, msg, data }。 -/// 客户机机鉴权:请求头 appKey + appSecret(勿放 Body,避免落日志)。 +/// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。 /// [ApiExplorerSettings(GroupName = "openApi")] public class OpenApiService : ApplicationService { + private static readonly JsonSerializerOptions BodyJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + private CustomerOpenApiAuthHelper AuthHelper => LazyServiceProvider.GetRequiredService(); @@ -25,6 +33,9 @@ public class OpenApiService : ApplicationService private OrderService OrderService => LazyServiceProvider.GetRequiredService(); + private IHttpContextAccessor HttpContextAccessor => + LazyServiceProvider.GetRequiredService(); + /// /// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。 /// @@ -52,20 +63,27 @@ public class OpenApiService : ApplicationService /// /// 创建订单包裹(仅自发货)。 - /// 鉴权:Header appKey、appSecret;客户归属强制取自凭证,Body 不可指定 customerId。 + /// Header:appKey、timestamp(Unix 秒)、sign。 + /// sign = hex(HMAC-SHA256(appSecret, appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)))。 /// [AllowAnonymous] [HttpPost("/openapi/parcel/create")] public async Task> CreateParcelAsync( [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey, - [FromHeader(Name = CustomerOpenApiAuthHelper.AppSecretHeaderName)] string? appSecret, - [FromBody] OpenApiParcelCreateInput input) + [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp, + [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign) { try { - ArgumentNullException.ThrowIfNull(input); + var rawBody = await ReadRawBodyAsync(); + var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody); + + var input = JsonSerializer.Deserialize(rawBody, BodyJsonOptions); + if (input == null) + { + throw new BusinessException("请求体不能为空"); + } - var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret); var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id)); return OpenApiEnvelope.Ok(data); } @@ -73,6 +91,10 @@ public class OpenApiService : ApplicationService { return OpenApiEnvelope.Fail(ex.Message); } + catch (JsonException) + { + return OpenApiEnvelope.Fail("请求体格式错误"); + } catch (Exception ex) { return OpenApiEnvelope.Fail( @@ -80,4 +102,25 @@ public class OpenApiService : ApplicationService "500"); } } + + private async Task ReadRawBodyAsync() + { + var http = HttpContextAccessor.HttpContext + ?? throw new BusinessException("凭证无效"); + var request = http.Request; + request.EnableBuffering(); + if (request.Body.CanSeek) + { + request.Body.Position = 0; + } + + using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var raw = await reader.ReadToEndAsync(); + if (request.Body.CanSeek) + { + request.Body.Position = 0; + } + + return raw; + } } diff --git a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs index b65658c..0306371 100644 --- a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs +++ b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs @@ -10,12 +10,16 @@ using Oms.Domain.CustomersAggregate.Repositories; namespace Oms.Application.Shared; /// -/// 客户 OpenAPI 机机鉴权:按请求头 AppKey + AppSecret 解析主客户。 +/// 客户 OpenAPI 机机鉴权:appKey + timestamp + HMAC-SHA256(sign),Secret 仅本地参与签名。 /// public class CustomerOpenApiAuthHelper : IScopedDependency { public const string AppKeyHeaderName = "appKey"; - public const string AppSecretHeaderName = "appSecret"; + public const string TimestampHeaderName = "timestamp"; + public const string SignHeaderName = "sign"; + + /// 签名时间窗(秒)。 + public const int TimestampSkewSeconds = 300; private readonly ICustomerOpenApiCredentialRepository _credentialRepository; private readonly ICustomerRepository _customerRepository; @@ -32,38 +36,52 @@ public class CustomerOpenApiAuthHelper : IScopedDependency } /// - /// 从当前请求头读取 appKey / appSecret 并鉴权。 + /// 校验 Header 签名并解析主客户。 + /// stringToSign = appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody) + /// sign = hex(HMAC-SHA256(appSecret, stringToSign)) /// - public Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveFromHeadersAsync( - CancellationToken cancellationToken = default) - { - var http = _httpContextAccessor.HttpContext - ?? throw new BusinessException("凭证无效"); - var appKey = http.Request.Headers[AppKeyHeaderName].FirstOrDefault(); - var appSecret = http.Request.Headers[AppSecretHeaderName].FirstOrDefault(); - return ResolveAsync(appKey, appSecret, cancellationToken); - } - - public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveAsync( + public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveSignedAsync( string? appKey, - string? appSecret, + string? timestamp, + string? sign, + string? rawBody, CancellationToken cancellationToken = default) { var key = appKey?.Trim(); - var secret = appSecret?.Trim(); - if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret)) + var tsRaw = timestamp?.Trim(); + var signRaw = sign?.Trim(); + if (string.IsNullOrWhiteSpace(key) + || string.IsNullOrWhiteSpace(tsRaw) + || string.IsNullOrWhiteSpace(signRaw)) { throw new BusinessException("凭证无效"); } + if (!long.TryParse(tsRaw, out var unixSeconds)) + { + throw new BusinessException("请求已过期"); + } + + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (Math.Abs(now - unixSeconds) > TimestampSkewSeconds) + { + throw new BusinessException("请求已过期"); + } + var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken); if (credential == null || !credential.Enabled - || !FixedTimeEqualsUtf8(credential.AppSecret, secret)) + || string.IsNullOrWhiteSpace(credential.AppSecret)) { throw new BusinessException("凭证无效"); } + var expectedSign = ComputeSign(key, tsRaw, rawBody ?? string.Empty, credential.AppSecret); + if (!FixedTimeEqualsUtf8(expectedSign, signRaw.ToLowerInvariant())) + { + throw new BusinessException("签名无效"); + } + var customer = await _customerRepository.FirstOrDefaultAsync( x => x.Id == credential.CustomerId, cancellationToken); @@ -81,6 +99,30 @@ public class CustomerOpenApiAuthHelper : IScopedDependency return (customer, credential); } + /// + /// 客户端可复用:计算 sign(小写 hex)。 + /// + public static string ComputeSign(string appKey, string timestamp, string rawBody, string appSecret) + { + var bodyHash = Sha256Hex(rawBody ?? string.Empty); + var stringToSign = $"{appKey}\n{timestamp}\n{bodyHash}"; + return HmacSha256Hex(appSecret, stringToSign); + } + + private static string Sha256Hex(string text) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static string HmacSha256Hex(string secret, string content) + { + var keyBytes = Encoding.UTF8.GetBytes(secret); + var contentBytes = Encoding.UTF8.GetBytes(content); + var hash = HMACSHA256.HashData(keyBytes, contentBytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + private static bool FixedTimeEqualsUtf8(string? expected, string actual) { if (string.IsNullOrEmpty(expected)) diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index 0d0e2a1..b24c7ff 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -167,6 +167,17 @@ public class SimpleAdminApiModule : FakeModule // 路由 - 必须在认证和授权之前 app.UseRouting(); + // OpenAPI 验签需读取原始 Body,允许二次读流 + app.Use(async (ctx, next) => + { + if (ctx.Request.Path.StartsWithSegments("/openapi")) + { + ctx.Request.EnableBuffering(); + } + + await next(); + }); + // 认证和授权 app.UseAuthentication(); app.UseAuthorization(); -- 2.45.2 From 79099776a52eaa0a6cb2310de4d132d024348d69 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 5 Oct 2026 11:23:38 +0800 Subject: [PATCH 17/41] =?UTF-8?q?feat:=20=E5=90=8C=E4=B8=80=E4=BA=A7?= =?UTF-8?q?=E5=93=81/=E6=B8=A0=E9=81=93=E4=B8=8B=E5=8D=96=E4=BB=B7?= =?UTF-8?q?=E4=B9=B0=E4=BB=B7=E8=B4=B9=E7=94=A8=E9=A1=B9=E5=94=AF=E4=B8=80?= =?UTF-8?q?=EF=BC=8C=E7=A6=81=E6=AD=A2=E9=87=8D=E5=A4=8D=E6=96=B0=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Channels/SalesProductService.Helpers.cs | 41 ++++++++++++++++++- .../Services/Channels/SalesProductService.cs | 2 + .../Channels/ServiceChannelService.Helpers.cs | 41 ++++++++++++++++++- .../Channels/ServiceChannelService.cs | 2 + 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 344f1ab..5cf568d 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -388,10 +388,11 @@ public partial class SalesProductService }; /// - /// 保险/偏远费等附加费用项的计费条件校验。 + /// 保险/偏远费等附加费用项的计费条件校验;同一提交批次内费用项不可重复。 /// private static void ValidateBillingPriceItemRules(IReadOnlyList prices) { + EnsureUniqueCostItemsInBatch(prices.Select(x => x.CostItem)); foreach (var price in prices) { var costItem = price.CostItem?.Trim() ?? string.Empty; @@ -417,6 +418,44 @@ public partial class SalesProductService } } + /// + /// 同一销售产品下每种费用项只能有一条卖价配置。 + /// + private async Task EnsureCostItemUniqueAsync(int salesProductId, string? costItem, int? excludePriceId) + { + var trimmed = costItem?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(trimmed)) + { + throw new Fake.Application.BusinessException("费用项不能为空"); + } + + var existing = await SalesProductRepository.GetPricesAsync(salesProductId); + var duplicated = existing.Any(p => + (!excludePriceId.HasValue || p.Id != excludePriceId.Value) + && string.Equals(p.CostItem?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase)); + if (duplicated) + { + throw new Fake.Application.BusinessException( + $"该产品已存在费用项「{trimmed}」,同一产品下每种费用项只能有一条"); + } + } + + private static void EnsureUniqueCostItemsInBatch(IEnumerable costItems) + { + var duplicates = costItems + .Select(x => x?.Trim() ?? string.Empty) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .GroupBy(x => x, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + if (duplicates.Count > 0) + { + throw new Fake.Application.BusinessException( + $"同一产品下费用项不能重复:{string.Join("、", duplicates)}"); + } + } + private static string? NormalizeAdditionalServices(string? raw) { if (string.IsNullOrWhiteSpace(raw)) diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index 3b86f15..29ddaf2 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -159,6 +159,7 @@ public partial class SalesProductService : ApplicationService { await GetProductOrThrowAsync(input.SalesProductId); ValidateBillingPriceItemRules([input]); + await EnsureCostItemUniqueAsync(input.SalesProductId, input.CostItem, excludePriceId: null); await SalesProductRepository.InsertPriceAsync( new SalesProductPrice { @@ -190,6 +191,7 @@ public partial class SalesProductService : ApplicationService } ValidateBillingPriceItemRules([input]); + await EnsureCostItemUniqueAsync(input.SalesProductId, input.CostItem, excludePriceId: price.Id); price.SerialNumber = input.SerialNumber; price.Status = input.Status; price.PriceName = input.PriceName; diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index 2a00598..0f8c182 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -329,10 +329,11 @@ public partial class ServiceChannelService } /// - /// 买价偏远费等附加费用项的计费条件校验。 + /// 买价偏远费等附加费用项的计费条件校验;同一提交批次内费用项不可重复。 /// private static void ValidateBillingPriceItemRules(IReadOnlyList prices) { + EnsureUniqueCostItemsInBatch(prices.Select(x => x.CostItem)); foreach (var price in prices) { var costItem = price.CostItem?.Trim() ?? string.Empty; @@ -349,6 +350,44 @@ public partial class ServiceChannelService } } + /// + /// 同一服务渠道下每种费用项只能有一条买价配置。 + /// + private async Task EnsureCostItemUniqueAsync(int serviceChannelId, string? costItem, int? excludePriceId) + { + var trimmed = costItem?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(trimmed)) + { + throw new Fake.Application.BusinessException("费用项不能为空"); + } + + var existing = await ServiceChannelRepository.GetPricesAsync(serviceChannelId); + var duplicated = existing.Any(p => + (!excludePriceId.HasValue || p.Id != excludePriceId.Value) + && string.Equals(p.CostItem?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase)); + if (duplicated) + { + throw new Fake.Application.BusinessException( + $"该渠道已存在费用项「{trimmed}」,同一渠道下每种费用项只能有一条"); + } + } + + private static void EnsureUniqueCostItemsInBatch(IEnumerable costItems) + { + var duplicates = costItems + .Select(x => x?.Trim() ?? string.Empty) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .GroupBy(x => x, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + if (duplicates.Count > 0) + { + throw new Fake.Application.BusinessException( + $"同一渠道下费用项不能重复:{string.Join("、", duplicates)}"); + } + } + private static ServiceChannelGroupListOutput MapGroupListOutput(ServiceChannelGroup entity) { return new ServiceChannelGroupListOutput diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index 7188468..137cd20 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -223,6 +223,7 @@ public partial class ServiceChannelService : ApplicationService { await GetEntityOrThrowAsync(input.ServiceChannelId); ValidateBillingPriceItemRules([input]); + await EnsureCostItemUniqueAsync(input.ServiceChannelId, input.CostItem, excludePriceId: null); await ServiceChannelRepository.InsertPriceAsync( new ServiceChannelPrice { @@ -254,6 +255,7 @@ public partial class ServiceChannelService : ApplicationService } ValidateBillingPriceItemRules([input]); + await EnsureCostItemUniqueAsync(input.ServiceChannelId, input.CostItem, excludePriceId: price.Id); price.SerialNumber = input.SerialNumber; price.Status = input.Status; price.PriceName = input.PriceName; -- 2.45.2 From b17e6639a4c7bde54ad9d287f2f7d2b107bfeb14 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 5 Oct 2026 11:53:08 +0800 Subject: [PATCH 18/41] =?UTF-8?q?fix:=20=E4=B8=BB=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=85=BC=E5=AE=B9=E8=BD=AF=E5=88=A0=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E9=94=AE=EF=BC=8C=E9=81=BF=E5=85=8D=20Duplicate=20ent?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Services/Ai/AiSkillService.cs | 9 ++++ .../Channels/SalesProductService.Helpers.cs | 15 ++++++ .../Services/Channels/SalesProductService.cs | 11 ++++ .../Channels/ServiceChannelService.Helpers.cs | 15 ++++++ .../Channels/ServiceChannelService.cs | 11 ++++ .../Services/Customers/CustomerService.cs | 52 +++++++++++++++++++ .../Services/Kb/KbCategoryService.cs | 17 +++++- .../Services/System/DictService.cs | 26 +++++++--- .../System/SystemOtherConfigService.cs | 29 ++++++++++- .../Services/System/WarehouseService.cs | 30 +++++++++-- .../Warehouse/WarehouseService.BinLocation.cs | 22 +++++++- .../Warehouse/WarehouseService.Sku.cs | 18 ++++++- .../Repositories/IAiSkillRepository.cs | 4 ++ .../Repositories/ISalesProductRepository.cs | 4 ++ .../Repositories/IServiceChannelRepository.cs | 4 ++ .../Repositories/ICustomerRepository.cs | 8 +++ .../Repositories/IKbCategoryRepository.cs | 2 + .../Repositories/IDictTypeRepository.cs | 7 +++ .../ISystemOtherConfigRepository.cs | 4 ++ .../IWarehouseAddressRepository.cs | 4 ++ .../Repositories/IWarehouseRepositories.cs | 16 +++++- src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs | 11 ++++ .../Channels/SalesProductRepository.cs | 11 ++++ .../Channels/ServiceChannelRepository.cs | 11 ++++ .../Customers/CustomerRepository.cs | 33 ++++++++++++ .../Kb/KbCategoryRepository.cs | 11 ++++ .../Repositories/SoftDeleteQuery.cs | 24 +++++++++ .../System/DictTypeRepository.cs | 13 +++++ .../System/SystemOtherConfigRepository.cs | 11 ++++ .../System/WarehouseAddressRepository.cs | 11 ++++ .../Warehouse/WarehouseRepositories.cs | 30 ++++++++++- 31 files changed, 451 insertions(+), 23 deletions(-) create mode 100644 src/Oms.SqlSugarCore/Repositories/SoftDeleteQuery.cs diff --git a/src/Oms.Application/Services/Ai/AiSkillService.cs b/src/Oms.Application/Services/Ai/AiSkillService.cs index 775d83f..ce85056 100644 --- a/src/Oms.Application/Services/Ai/AiSkillService.cs +++ b/src/Oms.Application/Services/Ai/AiSkillService.cs @@ -93,6 +93,7 @@ public class AiSkillService : ApplicationService } entity ??= await AiSkillRepository.GetBySkillKeyAsync(skillKey); + entity ??= await AiSkillRepository.GetBySkillKeyIncludingDeletedAsync(skillKey); if (entity == null) { @@ -106,9 +107,17 @@ public class AiSkillService : ApplicationService throw new BusinessException($"SkillKey 已存在:{skillKey}"); } + var softDeleted = await AiSkillRepository.GetBySkillKeyIncludingDeletedAsync(skillKey); + if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id) + { + softDeleted.SkillKey = $"{skillKey}__deleted_{softDeleted.Id}"; + await AiSkillRepository.UpdateAsync(softDeleted); + } + entity.SkillKey = skillKey; } + entity.IsDeleted = false; entity.Scene = scene; entity.Name = input.Name.Trim(); entity.Description = NormalizeOptional(input.Description); diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 5cf568d..5c3203d 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -23,6 +23,21 @@ public partial class SalesProductService } } + /// + /// 更新时若目标编码被软删记录占用,改名释放 uk_sp_code。 + /// + private async Task FreeSoftDeletedProductCodeIfNeededAsync(string productCode, int currentId) + { + var conflict = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(productCode); + if (conflict == null || !conflict.IsDeleted || conflict.Id == currentId) + { + return; + } + + conflict.ProductCode = $"{productCode}__deleted_{conflict.Id}"; + await SalesProductRepository.UpdateAsync(conflict); + } + /// /// 获取销售产品,不存在时直接抛异常。 /// diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index 29ddaf2..9ebf67f 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -75,6 +75,16 @@ public partial class SalesProductService : ApplicationService public async Task CreateAsync(SalesProductSaveInput input) { await ValidateProductCodeAsync(input.ProductCode, null); + var code = input.ProductCode.Trim(); + var deleted = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(code); + if (deleted != null && deleted.IsDeleted) + { + deleted.IsDeleted = false; + ApplyProductInput(deleted, input); + await SalesProductRepository.UpdateAsync(deleted); + return; + } + var entity = new SalesProduct(); ApplyProductInput(entity, input); await SalesProductRepository.InsertAsync(entity); @@ -90,6 +100,7 @@ public partial class SalesProductService : ApplicationService { var entity = await GetProductOrThrowAsync(input.Id); await ValidateProductCodeAsync(input.ProductCode, entity.Id); + await FreeSoftDeletedProductCodeIfNeededAsync(input.ProductCode.Trim(), entity.Id); ApplyProductInput(entity, input); await SalesProductRepository.UpdateAsync(entity); } diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index 0f8c182..eb303e8 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -91,6 +91,21 @@ public partial class ServiceChannelService } } + /// + /// 更新时若目标编码被软删记录占用,改名释放 uk_sc_code。 + /// + private async Task FreeSoftDeletedChannelCodeIfNeededAsync(string channelCode, int currentId) + { + var conflict = await ServiceChannelRepository.GetByChannelCodeIncludingDeletedAsync(channelCode); + if (conflict == null || !conflict.IsDeleted || conflict.Id == currentId) + { + return; + } + + conflict.ChannelCode = $"{channelCode}__deleted_{conflict.Id}"; + await ServiceChannelRepository.UpdateAsync(conflict); + } + /// /// 获取服务渠道,不存在时直接抛异常。 /// diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index 137cd20..061ab28 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -73,6 +73,16 @@ public partial class ServiceChannelService : ApplicationService public async Task CreateAsync(ServiceChannelSaveInput input) { await ValidateChannelCodeAsync(input.ChannelCode, null); + var code = input.ChannelCode.Trim(); + var deleted = await ServiceChannelRepository.GetByChannelCodeIncludingDeletedAsync(code); + if (deleted != null && deleted.IsDeleted) + { + deleted.IsDeleted = false; + ApplyInput(deleted, input); + await ServiceChannelRepository.UpdateAsync(deleted); + return; + } + var entity = new ServiceChannel(); ApplyInput(entity, input); await ServiceChannelRepository.InsertAsync(entity); @@ -88,6 +98,7 @@ public partial class ServiceChannelService : ApplicationService { var entity = await GetEntityOrThrowAsync(input.Id); await ValidateChannelCodeAsync(input.ChannelCode, entity.Id); + await FreeSoftDeletedChannelCodeIfNeededAsync(input.ChannelCode.Trim(), entity.Id); ApplyInput(entity, input); await ServiceChannelRepository.UpdateAsync(entity); } diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index 21550cd..8e6bed7 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -178,6 +178,12 @@ public class CustomerService : ApplicationService NormalizeRequiredText(input.Email, "邮箱"), WeChatRegistrationFieldNormalizer.EmailMaxLength); + await FreeSoftDeletedCustomerIdentityIfNeededAsync( + customer.CustomerCode, + phone, + email, + excludeId: customer.Id); + var duplicatedPhone = await CustomerRepository.GetByPhoneAsync(phone, cancellationToken); if (duplicatedPhone != null && duplicatedPhone.Id != customer.Id) { @@ -216,6 +222,10 @@ public class CustomerService : ApplicationService { var normalized = await NormalizeCreateCustomerInputAsync(input, "客户编号", "客户名称"); + await FreeSoftDeletedCustomerIdentityIfNeededAsync( + normalized.CustomerCode, + normalized.Phone, + normalized.Email); await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email); var customerRoleId = await GetCustomerRoleIdOrThrowAsync(); @@ -268,6 +278,10 @@ public class CustomerService : ApplicationService var normalized = await NormalizeCreateCustomerInputAsync(input, "子客户编号", "子客户名称"); var permissions = await NormalizeAndValidateSubCustomerPermissionsAsync(input.Permissions); + await FreeSoftDeletedCustomerIdentityIfNeededAsync( + normalized.CustomerCode, + normalized.Phone, + normalized.Email); await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email); var user = await CreateRbacUserAsync(input, normalized); @@ -550,8 +564,46 @@ public class CustomerService : ApplicationService } } + /// + /// 客户创建会新建 RBAC 用户,不适合直接恢复软删行;释放软删占用的唯一键即可。 + /// + private async Task FreeSoftDeletedCustomerIdentityIfNeededAsync( + string customerCode, + string phone, + string? email, + int? excludeId = null) + { + var byCode = await CustomerRepository.GetByCustomerCodeIncludingDeletedAsync(customerCode); + if (byCode != null && byCode.IsDeleted && byCode.Id != excludeId) + { + byCode.CustomerCode = $"{customerCode}__deleted_{byCode.Id}"; + await CustomerRepository.UpdateAsync(byCode); + } + + var byPhone = await CustomerRepository.GetByPhoneIncludingDeletedAsync(phone); + if (byPhone != null && byPhone.IsDeleted && byPhone.Id != excludeId) + { + byPhone.Phone = $"{phone}__deleted_{byPhone.Id}"; + await CustomerRepository.UpdateAsync(byPhone); + } + + if (string.IsNullOrWhiteSpace(email)) + { + return; + } + + var byEmail = await CustomerRepository.GetByEmailIncludingDeletedAsync(email); + if (byEmail != null && byEmail.IsDeleted && byEmail.Id != excludeId) + { + byEmail.Email = $"{email}__deleted_{byEmail.Id}"; + await CustomerRepository.UpdateAsync(byEmail); + } + } + private async Task EnsureCustomerIdentityUniqueForUpdateAsync(int id, string customerCode, string phone, string? email) { + await FreeSoftDeletedCustomerIdentityIfNeededAsync(customerCode, phone, email, excludeId: id); + var duplicatedCode = await CustomerRepository.GetByCustomerCodeAsync(customerCode); if (duplicatedCode != null && duplicatedCode.Id != id) { diff --git a/src/Oms.Application/Services/Kb/KbCategoryService.cs b/src/Oms.Application/Services/Kb/KbCategoryService.cs index a42186e..492845c 100644 --- a/src/Oms.Application/Services/Kb/KbCategoryService.cs +++ b/src/Oms.Application/Services/Kb/KbCategoryService.cs @@ -64,7 +64,22 @@ public class KbCategoryService : ApplicationService throw new BusinessException($"分类编码已存在:{code}"); } - entity ??= new KbCategory(); + if (entity == null) + { + var deleted = await CategoryRepository.GetByCodeIncludingDeletedAsync(code); + entity = deleted is { IsDeleted: true } ? deleted : new KbCategory(); + } + else + { + var softDeleted = await CategoryRepository.GetByCodeIncludingDeletedAsync(code); + if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id) + { + softDeleted.Code = $"{code}__deleted_{softDeleted.Id}"; + await CategoryRepository.UpdateAsync(softDeleted); + } + } + + entity.IsDeleted = false; entity.Code = code; entity.Name = name; entity.SortOrder = input.SortOrder; diff --git a/src/Oms.Application/Services/System/DictService.cs b/src/Oms.Application/Services/System/DictService.cs index 2bd2737..7329353 100644 --- a/src/Oms.Application/Services/System/DictService.cs +++ b/src/Oms.Application/Services/System/DictService.cs @@ -132,14 +132,10 @@ public class DictService : ApplicationService } entity ??= await DictTypeRepository.GetByDictTypeAsync(dictType); + // 软删后列表查不到,但 uk_dict_type_code 仍占坑;重建时恢复原行 + entity ??= await DictTypeRepository.GetByDictTypeIncludingDeletedAsync(dictType); if (entity == null) { - var existedType = await DictTypeRepository.GetByDictTypeAsync(dictType); - if (existedType != null) - { - throw new BusinessException("字典类型已存在"); - } - await DictTypeRepository.InsertAsync(new DictTypeMeta { DictType = dictType, @@ -151,13 +147,19 @@ public class DictService : ApplicationService if (!string.Equals(entity.DictType, dictType, StringComparison.OrdinalIgnoreCase)) { - var duplicate = await DictTypeRepository.GetByDictTypeAsync(dictType); - if (duplicate != null && duplicate.Id != entity.Id) + var duplicate = await DictTypeRepository.GetByDictTypeIncludingDeletedAsync(dictType); + if (duplicate != null && duplicate.Id != entity.Id && !duplicate.IsDeleted) { throw new BusinessException("字典类型已存在"); } + + if (duplicate != null && duplicate.Id != entity.Id && duplicate.IsDeleted) + { + throw new BusinessException("字典类型编码与已删除记录冲突,请换编码或联系管理员恢复"); + } } + entity.IsDeleted = false; entity.DictType = dictType; entity.Remark = remark; entity.TypeSort = input.TypeSort; @@ -317,6 +319,14 @@ public class DictService : ApplicationService return; } + var deleted = await DictTypeRepository.GetByDictTypeIncludingDeletedAsync(dictType); + if (deleted != null) + { + deleted.IsDeleted = false; + await DictTypeRepository.UpdateAsync(deleted); + return; + } + await DictTypeRepository.InsertAsync(new DictTypeMeta { DictType = dictType, diff --git a/src/Oms.Application/Services/System/SystemOtherConfigService.cs b/src/Oms.Application/Services/System/SystemOtherConfigService.cs index b213f87..7c52138 100644 --- a/src/Oms.Application/Services/System/SystemOtherConfigService.cs +++ b/src/Oms.Application/Services/System/SystemOtherConfigService.cs @@ -61,9 +61,26 @@ public class SystemOtherConfigService : ApplicationService public async Task CreateAsync(SystemOtherConfigCreateInput input) { await ValidateInputAsync(input, null); + var configCode = input.ConfigCode.Trim(); + var deleted = await SystemOtherConfigRepository.GetByConfigCodeIncludingDeletedAsync(configCode); + if (deleted != null && deleted.IsDeleted) + { + deleted.IsDeleted = false; + deleted.ConfigCode = configCode; + deleted.ConfigName = input.ConfigName.Trim(); + deleted.Enabled = input.Enabled; + deleted.BaseUrl = NormalizeOptional(input.BaseUrl); + deleted.AppKey = NormalizeOptional(input.AppKey); + deleted.AppSecret = NormalizeOptional(input.AppSecret); + deleted.TimeoutSeconds = NormalizeTimeoutSeconds(input.TimeoutSeconds); + deleted.Remark = NormalizeOptional(input.Remark); + await SystemOtherConfigRepository.UpdateAsync(deleted); + return; + } + await SystemOtherConfigRepository.InsertAsync(new SystemOtherConfig { - ConfigCode = input.ConfigCode.Trim(), + ConfigCode = configCode, ConfigName = input.ConfigName.Trim(), Enabled = input.Enabled, BaseUrl = NormalizeOptional(input.BaseUrl), @@ -82,8 +99,16 @@ public class SystemOtherConfigService : ApplicationService { var entity = await GetConfigOrThrowAsync(input.Id); await ValidateInputAsync(input, entity.Id); + var configCode = input.ConfigCode.Trim(); + var softDeleted = await SystemOtherConfigRepository.GetByConfigCodeIncludingDeletedAsync(configCode); + if (softDeleted != null && softDeleted.IsDeleted && softDeleted.Id != entity.Id) + { + softDeleted.ConfigCode = $"{configCode}__deleted_{softDeleted.Id}"; + await SystemOtherConfigRepository.UpdateAsync(softDeleted); + } + var clearWeChatAccessToken = ShouldClearWeChatAccessToken(entity, input); - entity.ConfigCode = input.ConfigCode.Trim(); + entity.ConfigCode = configCode; entity.ConfigName = input.ConfigName.Trim(); entity.Enabled = input.Enabled; entity.BaseUrl = NormalizeOptional(input.BaseUrl); diff --git a/src/Oms.Application/Services/System/WarehouseService.cs b/src/Oms.Application/Services/System/WarehouseService.cs index 2e27f6c..8532769 100644 --- a/src/Oms.Application/Services/System/WarehouseService.cs +++ b/src/Oms.Application/Services/System/WarehouseService.cs @@ -81,15 +81,29 @@ public class WarehouseService : ApplicationService [HttpPost("/system/warehouse/create")] public async Task CreateAsync(CreateWarehouseInput input) { - var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(input.WarehouseName); + var warehouseName = input.WarehouseName.Trim(); + var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(warehouseName); if (duplicatedWarehouse != null) { throw new BusinessException("仓库名称已存在"); } + var deleted = await WarehouseAddressRepository.GetByWarehouseNameIncludingDeletedAsync(warehouseName); + if (deleted != null && deleted.IsDeleted) + { + deleted.IsDeleted = false; + deleted.WarehouseName = warehouseName; + deleted.WarehouseType = input.WarehouseType; + deleted.Recipient = input.Recipient; + deleted.ContactPhone = input.ContactPhone; + deleted.ShippingAddress = input.ShippingAddress; + await WarehouseAddressRepository.UpdateAsync(deleted); + return; + } + var entity = new WarehouseAddress { - WarehouseName = input.WarehouseName, + WarehouseName = warehouseName, WarehouseType = input.WarehouseType, Recipient = input.Recipient, ContactPhone = input.ContactPhone, @@ -108,13 +122,21 @@ public class WarehouseService : ApplicationService public async Task UpdateAsync(UpdateWarehouseInput input) { var entity = await GetWarehouseOrThrowAsync(input.Id); - var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(input.WarehouseName); + var warehouseName = input.WarehouseName.Trim(); + var duplicatedWarehouse = await WarehouseAddressRepository.GetByWarehouseNameAsync(warehouseName); if (duplicatedWarehouse != null && duplicatedWarehouse.Id != entity.Id) { throw new BusinessException("仓库名称已存在"); } - entity.WarehouseName = input.WarehouseName; + var softDeleted = await WarehouseAddressRepository.GetByWarehouseNameIncludingDeletedAsync(warehouseName); + if (softDeleted != null && softDeleted.IsDeleted && softDeleted.Id != entity.Id) + { + softDeleted.WarehouseName = $"{warehouseName}__deleted_{softDeleted.Id}"; + await WarehouseAddressRepository.UpdateAsync(softDeleted); + } + + entity.WarehouseName = warehouseName; entity.WarehouseType = input.WarehouseType; entity.Recipient = input.Recipient; entity.ContactPhone = input.ContactPhone; diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs index 0c1327e..3fe21b9 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs @@ -63,7 +63,12 @@ public partial class WarehouseService } else { - entity = new CBinLocation { WarehouseId = input.WarehouseId }; + var deleted = await BinLocationRepository.GetByWarehouseAndBinCodeIncludingDeletedAsync( + input.WarehouseId, + binCode); + entity = deleted is { IsDeleted: true } + ? deleted + : new CBinLocation { WarehouseId = input.WarehouseId }; } var duplicate = await BinLocationRepository.FirstOrDefaultAsync(x => @@ -73,13 +78,26 @@ public partial class WarehouseService throw new BusinessException("库位编码已存在"); } + if (input.Id > 0) + { + var softDeleted = await BinLocationRepository.GetByWarehouseAndBinCodeIncludingDeletedAsync( + input.WarehouseId, + binCode); + if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id) + { + softDeleted.BinCode = $"{binCode}__deleted_{softDeleted.Id}"; + await BinLocationRepository.UpdateAsync(softDeleted); + } + } + + entity.IsDeleted = false; entity.BinCode = binCode; entity.BinName = string.IsNullOrWhiteSpace(input.BinName) ? null : input.BinName.Trim(); entity.ZoneCode = string.IsNullOrWhiteSpace(input.ZoneCode) ? null : input.ZoneCode.Trim(); entity.IsEnabled = input.IsEnabled; entity.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim(); - if (input.Id > 0) + if (entity.Id > 0) { await BinLocationRepository.UpdateAsync(entity); } diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs index 646e35f..c0b0eaa 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs @@ -105,7 +105,10 @@ public partial class WarehouseService } else { - entity = new Domain.WarehouseAggregate.Entities.CSku { CustomerId = customerId }; + var deleted = await SkuRepository.GetByCustomerAndSkuCodeIncludingDeletedAsync(customerId, skuCode); + entity = deleted is { IsDeleted: true } + ? deleted + : new Domain.WarehouseAggregate.Entities.CSku { CustomerId = customerId }; } var duplicate = await SkuRepository.FirstOrDefaultAsync(x => @@ -115,6 +118,17 @@ public partial class WarehouseService throw new BusinessException("SKU 编码已存在"); } + if (input.Id > 0) + { + var softDeleted = await SkuRepository.GetByCustomerAndSkuCodeIncludingDeletedAsync(customerId, skuCode); + if (softDeleted is { IsDeleted: true } && softDeleted.Id != entity.Id) + { + softDeleted.SkuCode = $"{skuCode}__deleted_{softDeleted.Id}"; + await SkuRepository.UpdateAsync(softDeleted); + } + } + + entity.IsDeleted = false; entity.SkuCode = skuCode; entity.SkuName = skuName; entity.SkuNameEn = string.IsNullOrWhiteSpace(input.SkuNameEn) ? null : input.SkuNameEn.Trim(); @@ -129,7 +143,7 @@ public partial class WarehouseService entity.HeightCm = input.HeightCm; entity.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim(); - if (input.Id > 0) + if (entity.Id > 0) { await SkuRepository.UpdateAsync(entity); } diff --git a/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs b/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs index 8dfae78..bf79a04 100644 --- a/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs +++ b/src/Oms.Domain/AiAggregate/Repositories/IAiSkillRepository.cs @@ -6,4 +6,8 @@ namespace Oms.Domain.AiAggregate.Repositories; public interface IAiSkillRepository : IRepository { Task GetBySkillKeyAsync(string skillKey, CancellationToken cancellationToken = default); + + Task GetBySkillKeyIncludingDeletedAsync( + string skillKey, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs index ea41dad..5ba942a 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs @@ -7,6 +7,10 @@ public interface ISalesProductRepository : IRepository { Task GetByProductCodeAsync(string productCode, CancellationToken cancellationToken = default); + Task GetByProductCodeIncludingDeletedAsync( + string productCode, + CancellationToken cancellationToken = default); + Task HasProductCodeAsync(string productCode, CancellationToken cancellationToken = default); Task> GetPricesAsync(int salesProductId, CancellationToken cancellationToken = default); diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs index 0ae8a9f..bb090e5 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs @@ -7,6 +7,10 @@ public interface IServiceChannelRepository : IRepository { Task GetByChannelCodeAsync(string channelCode, CancellationToken cancellationToken = default); + Task GetByChannelCodeIncludingDeletedAsync( + string channelCode, + CancellationToken cancellationToken = default); + Task HasChannelCodeAsync(string channelCode, CancellationToken cancellationToken = default); Task> GetGroupsAsync(CancellationToken cancellationToken = default); diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs index 7292f30..a46ba16 100644 --- a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs +++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs @@ -7,10 +7,18 @@ public interface ICustomerRepository : IRepository { Task GetByCustomerCodeAsync(string customerCode, CancellationToken cancellationToken = default); + Task GetByCustomerCodeIncludingDeletedAsync( + string customerCode, + CancellationToken cancellationToken = default); + Task GetByPhoneAsync(string phone, CancellationToken cancellationToken = default); + Task GetByPhoneIncludingDeletedAsync(string phone, CancellationToken cancellationToken = default); + Task GetByEmailAsync(string email, CancellationToken cancellationToken = default); + Task GetByEmailIncludingDeletedAsync(string email, CancellationToken cancellationToken = default); + Task GetByWechatOpenidAsync(string wechatOpenid, CancellationToken cancellationToken = default); Task ExistsPhoneAsync(string phone, CancellationToken cancellationToken = default); diff --git a/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs b/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs index 4fe86d9..f10c44f 100644 --- a/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs +++ b/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs @@ -7,5 +7,7 @@ public interface IKbCategoryRepository : IRepository { Task GetByCodeAsync(string code, CancellationToken cancellationToken = default); + Task GetByCodeIncludingDeletedAsync(string code, CancellationToken cancellationToken = default); + Task ExistsCodeAsync(string code, int? excludeId = null, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/SystemAggregate/Repositories/IDictTypeRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/IDictTypeRepository.cs index fb0e07f..3b1a4aa 100644 --- a/src/Oms.Domain/SystemAggregate/Repositories/IDictTypeRepository.cs +++ b/src/Oms.Domain/SystemAggregate/Repositories/IDictTypeRepository.cs @@ -6,4 +6,11 @@ namespace Oms.Domain.SystemAggregate.Repositories; public interface IDictTypeRepository : IRepository { Task GetByDictTypeAsync(string dictType, CancellationToken cancellationToken = default); + + /// + /// 按类型编码查询(含软删除),用于唯一键冲突时恢复。 + /// + Task GetByDictTypeIncludingDeletedAsync( + string dictType, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/SystemAggregate/Repositories/ISystemOtherConfigRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/ISystemOtherConfigRepository.cs index b1b3ffe..3b92cd8 100644 --- a/src/Oms.Domain/SystemAggregate/Repositories/ISystemOtherConfigRepository.cs +++ b/src/Oms.Domain/SystemAggregate/Repositories/ISystemOtherConfigRepository.cs @@ -6,4 +6,8 @@ namespace Oms.Domain.SystemAggregate.Repositories; public interface ISystemOtherConfigRepository : IRepository { Task GetByConfigCodeAsync(string configCode, CancellationToken cancellationToken = default); + + Task GetByConfigCodeIncludingDeletedAsync( + string configCode, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/SystemAggregate/Repositories/IWarehouseAddressRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/IWarehouseAddressRepository.cs index 16bd587..131abb9 100644 --- a/src/Oms.Domain/SystemAggregate/Repositories/IWarehouseAddressRepository.cs +++ b/src/Oms.Domain/SystemAggregate/Repositories/IWarehouseAddressRepository.cs @@ -7,5 +7,9 @@ public interface IWarehouseAddressRepository : IRepository { Task GetByWarehouseNameAsync(string warehouseName, CancellationToken cancellationToken = default); + Task GetByWarehouseNameIncludingDeletedAsync( + string warehouseName, + CancellationToken cancellationToken = default); + Task ExistsWarehouseNameAsync(string warehouseName, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs b/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs index f2c0fb8..26c7073 100644 --- a/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs +++ b/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs @@ -3,9 +3,21 @@ using Oms.Domain.WarehouseAggregate.Entities; namespace Oms.Domain.WarehouseAggregate.Repositories; -public interface ICSkuRepository : IRepository; +public interface ICSkuRepository : IRepository +{ + Task GetByCustomerAndSkuCodeIncludingDeletedAsync( + int customerId, + string skuCode, + CancellationToken cancellationToken = default); +} -public interface ICBinLocationRepository : IRepository; +public interface ICBinLocationRepository : IRepository +{ + Task GetByWarehouseAndBinCodeIncludingDeletedAsync( + int warehouseId, + string binCode, + CancellationToken cancellationToken = default); +} public interface ICInventoryRepository : IRepository; diff --git a/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs b/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs index cac80c7..4204742 100644 --- a/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs +++ b/src/Oms.SqlSugarCore/Ai/AiSkillRepository.cs @@ -12,4 +12,15 @@ public class AiSkillRepository string skillKey, CancellationToken cancellationToken = default) => FirstOrDefaultAsync(x => x.SkillKey == skillKey, cancellationToken); + + public async Task GetBySkillKeyIncludingDeletedAsync( + string skillKey, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.SkillKey == skillKey, + cancellationToken); + } } diff --git a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs index b6a1aab..fdc3af9 100644 --- a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs @@ -13,6 +13,17 @@ public class SalesProductRepository : OmsSqlSugarRepository, ISale return FirstOrDefaultAsync(x => x.ProductCode == productCode, cancellationToken); } + public async Task GetByProductCodeIncludingDeletedAsync( + string productCode, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.ProductCode == productCode, + cancellationToken); + } + public Task HasProductCodeAsync(string productCode, CancellationToken cancellationToken = default) { return AnyAsync(x => x.ProductCode == productCode, cancellationToken); diff --git a/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs b/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs index e7e80f2..c0c6e49 100644 --- a/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs @@ -13,6 +13,17 @@ public class ServiceChannelRepository : OmsSqlSugarRepository, I return FirstOrDefaultAsync(x => x.ChannelCode == channelCode, cancellationToken); } + public async Task GetByChannelCodeIncludingDeletedAsync( + string channelCode, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.ChannelCode == channelCode, + cancellationToken); + } + public Task HasChannelCodeAsync(string channelCode, CancellationToken cancellationToken = default) { return AnyAsync(x => x.ChannelCode == channelCode, cancellationToken); diff --git a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs index 22ed7bc..6215bd9 100644 --- a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs +++ b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs @@ -12,16 +12,49 @@ public class CustomerRepository : OmsSqlSugarRepository, ICustomerRepo return FirstOrDefaultAsync(x => x.CustomerCode == customerCode, cancellationToken); } + public async Task GetByCustomerCodeIncludingDeletedAsync( + string customerCode, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.CustomerCode == customerCode, + cancellationToken); + } + public Task GetByPhoneAsync(string phone, CancellationToken cancellationToken = default) { return FirstOrDefaultAsync(x => x.Phone == phone, cancellationToken); } + public async Task GetByPhoneIncludingDeletedAsync( + string phone, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.Phone == phone, + cancellationToken); + } + public Task GetByEmailAsync(string email, CancellationToken cancellationToken = default) { return FirstOrDefaultAsync(x => x.Email == email, cancellationToken); } + public async Task GetByEmailIncludingDeletedAsync( + string email, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.Email == email, + cancellationToken); + } + public Task GetByWechatOpenidAsync(string wechatOpenid, CancellationToken cancellationToken = default) { return FirstOrDefaultAsync(x => x.WechatOpenid == wechatOpenid, cancellationToken); diff --git a/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs b/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs index f6fdc90..0bd8fce 100644 --- a/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs +++ b/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs @@ -11,6 +11,17 @@ public class KbCategoryRepository public Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) => FirstOrDefaultAsync(x => x.Code == code, cancellationToken); + public async Task GetByCodeIncludingDeletedAsync( + string code, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.Code == code, + cancellationToken); + } + public async Task ExistsCodeAsync( string code, int? excludeId = null, diff --git a/src/Oms.SqlSugarCore/Repositories/SoftDeleteQuery.cs b/src/Oms.SqlSugarCore/Repositories/SoftDeleteQuery.cs new file mode 100644 index 0000000..f77ab9a --- /dev/null +++ b/src/Oms.SqlSugarCore/Repositories/SoftDeleteQuery.cs @@ -0,0 +1,24 @@ +using System.Linq.Expressions; +using SqlSugar; + +namespace Oms.SqlSugarCore.Repositories; + +/// +/// 软删场景下按业务唯一键查询(ClearFilter),供新增恢复 / 释放唯一键使用。 +/// +public static class SoftDeleteQuery +{ + public static async Task FirstIncludingDeletedAsync( + ISqlSugarClient db, + Expression> predicate, + CancellationToken cancellationToken = default) + where TEntity : class, new() + { + var rows = await db.Queryable() + .ClearFilter() + .Where(predicate) + .Take(1) + .ToListAsync(cancellationToken); + return rows.FirstOrDefault(); + } +} diff --git a/src/Oms.SqlSugarCore/System/DictTypeRepository.cs b/src/Oms.SqlSugarCore/System/DictTypeRepository.cs index b52693e..e76441f 100644 --- a/src/Oms.SqlSugarCore/System/DictTypeRepository.cs +++ b/src/Oms.SqlSugarCore/System/DictTypeRepository.cs @@ -11,4 +11,17 @@ public class DictTypeRepository : OmsSqlSugarRepository, IDictType { return FirstOrDefaultAsync(x => x.DictType == dictType, cancellationToken); } + + public async Task GetByDictTypeIncludingDeletedAsync( + string dictType, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var rows = await db.Queryable() + .ClearFilter() + .Where(x => x.DictType == dictType) + .Take(1) + .ToListAsync(cancellationToken); + return rows.FirstOrDefault(); + } } diff --git a/src/Oms.SqlSugarCore/System/SystemOtherConfigRepository.cs b/src/Oms.SqlSugarCore/System/SystemOtherConfigRepository.cs index e71892b..40bcfeb 100644 --- a/src/Oms.SqlSugarCore/System/SystemOtherConfigRepository.cs +++ b/src/Oms.SqlSugarCore/System/SystemOtherConfigRepository.cs @@ -11,4 +11,15 @@ public class SystemOtherConfigRepository : OmsSqlSugarRepository x.ConfigCode == configCode, cancellationToken); } + + public async Task GetByConfigCodeIncludingDeletedAsync( + string configCode, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.ConfigCode == configCode, + cancellationToken); + } } diff --git a/src/Oms.SqlSugarCore/System/WarehouseAddressRepository.cs b/src/Oms.SqlSugarCore/System/WarehouseAddressRepository.cs index e6233c5..40a7eb1 100644 --- a/src/Oms.SqlSugarCore/System/WarehouseAddressRepository.cs +++ b/src/Oms.SqlSugarCore/System/WarehouseAddressRepository.cs @@ -12,6 +12,17 @@ public class WarehouseAddressRepository : OmsSqlSugarRepository x.WarehouseName == warehouseName, cancellationToken); } + public async Task GetByWarehouseNameIncludingDeletedAsync( + string warehouseName, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.WarehouseName == warehouseName, + cancellationToken); + } + public Task ExistsWarehouseNameAsync(string warehouseName, CancellationToken cancellationToken = default) { return AnyAsync(x => x.WarehouseName == warehouseName, cancellationToken); diff --git a/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs b/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs index c9e6193..08ecd97 100644 --- a/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs +++ b/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs @@ -5,9 +5,35 @@ using Oms.SqlSugarCore.Repositories; namespace Oms.SqlSugarCore.Warehouse; -public class CSkuRepository : OmsSqlSugarRepository, ICSkuRepository, IScopedDependency; +public class CSkuRepository : OmsSqlSugarRepository, ICSkuRepository, IScopedDependency +{ + public async Task GetByCustomerAndSkuCodeIncludingDeletedAsync( + int customerId, + string skuCode, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.CustomerId == customerId && x.SkuCode == skuCode, + cancellationToken); + } +} -public class CBinLocationRepository : OmsSqlSugarRepository, ICBinLocationRepository, IScopedDependency; +public class CBinLocationRepository : OmsSqlSugarRepository, ICBinLocationRepository, IScopedDependency +{ + public async Task GetByWarehouseAndBinCodeIncludingDeletedAsync( + int warehouseId, + string binCode, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.WarehouseId == warehouseId && x.BinCode == binCode, + cancellationToken); + } +} public class CInventoryRepository : OmsSqlSugarRepository, ICInventoryRepository, IScopedDependency; -- 2.45.2 From 74a71991b28fcde2db7abbd033a53f655e1f5f6b Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 5 Oct 2026 16:12:09 +0800 Subject: [PATCH 19/41] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E4=BB=B7?= =?UTF-8?q?=E6=A0=BC/=E9=BB=91=E5=90=8D=E5=8D=95/=E5=81=8F=E8=BF=9C?= =?UTF-8?q?=E5=88=86=E5=8C=BA=E5=8C=B9=E9=85=8D=EF=BC=8C=E5=B9=B6=E8=90=BD?= =?UTF-8?q?=E5=BA=93=20MatchDim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- db.sql | 6 +- sql/admin.sql | 361 +++++++++ sql/migrate_kpi_area_info_match.sql | 66 ++ .../Dtos/Channels/KpiAreaInfoOutput.cs | 15 + .../Dtos/Channels/KpiAreaInfoSaveInput.cs | 15 + .../Dtos/Orders/OrderPriceCheckInput.cs | 5 + .../Dtos/Orders/ProductBillingSnapshotDtos.cs | 6 +- .../Dtos/Orders/PublishSnapshotRuleDtos.cs | 10 +- .../Services/Channels/KpiAreaService.cs | 31 +- .../SalesProductService.BillingSimulate.cs | 9 +- .../Orders/OrderService.ParcelBilling.cs | 31 +- .../Orders/OrderService.ParcelQuote.cs | 10 +- .../Orders/ParcelBillingCalculator.cs | 63 +- .../Services/Orders/PriceCheckHintBuilder.cs | 6 +- .../Services/Orders/PriceCheckInputProfile.cs | 6 + .../Shared/GeoPartitionMatcher.cs | 751 ++++++++++++++++++ .../Shared/KpiAreaInfoImportHelper.cs | 49 +- .../Shared/KpiAreaSnapshotMapper.cs | 87 +- .../Shared/RulesSnapshotHelper.cs | 7 +- .../ChannelsAggregate/Entities/KpiAreaInfo.cs | 8 + src/Oms.Tests/GeoPartitionMatcherTests.cs | 145 ++++ src/Oms.Tests/KpiAreaInfoImportHelperTests.cs | 9 +- src/Oms.Tests/KpiAreaSnapshotMapperTests.cs | 20 +- 23 files changed, 1526 insertions(+), 190 deletions(-) create mode 100644 sql/admin.sql create mode 100644 sql/migrate_kpi_area_info_match.sql create mode 100644 src/Oms.Application/Shared/GeoPartitionMatcher.cs create mode 100644 src/Oms.Tests/GeoPartitionMatcherTests.cs diff --git a/db.sql b/db.sql index 876be41..009c706 100644 --- a/db.sql +++ b/db.sql @@ -793,6 +793,9 @@ CREATE TABLE `t_kpi_area_info` ( `City` VARCHAR(128) NULL COMMENT '城市', `LeftPostcode` VARCHAR(32) NULL COMMENT '左侧邮编', `RightPostcode` VARCHAR(32) NULL COMMENT '右侧邮编', + `MatchDim` VARCHAR(16) NOT NULL DEFAULT 'country' COMMENT '匹配维度:zip/state/city/country', + `MatchMode` VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '匹配方式:prefix/exact/range/all', + `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT '邮编前缀长度(zip+prefix)', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', @@ -800,7 +803,8 @@ CREATE TABLE `t_kpi_area_info` ( `UpdateTime` DATETIME NULL COMMENT '更新时间', PRIMARY KEY (`Id`), KEY `idx_kpi_info_area` (`AreaId`), - KEY `idx_kpi_info_area_country` (`AreaId`, `CountryCode`) + KEY `idx_kpi_info_area_country` (`AreaId`, `CountryCode`), + KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`) ) COMMENT='分区明细表'; CREATE TABLE `t_weight_rule` ( diff --git a/sql/admin.sql b/sql/admin.sql new file mode 100644 index 0000000..07f3ba7 --- /dev/null +++ b/sql/admin.sql @@ -0,0 +1,361 @@ +-- ============================================================================= +-- 存量库增量迁移(合并自 sql/migrate_*.sql) +-- 可按整文件执行;多数段可重复执行(information_schema 判断)。 +-- 注意:揽收 Direction / FeePush 两段为裸 ALTER,已执行过的库请跳过或手工注释。 +-- ============================================================================= + +SET @db := DATABASE(); + +-- ----------------------------------------------------------------------------- +-- 1) 排货规则服务商表:MatchCondition +-- 原:migrate_allocation_match_condition.sql +-- ----------------------------------------------------------------------------- +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_sales_product_allocation_provider' + AND COLUMN_NAME = 'MatchCondition' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_sales_product_allocation_provider` ADD COLUMN `MatchCondition` TEXT NULL COMMENT ''排货判断条件(中文公式),空表示全部订单可用'' AFTER `CustomerIds`', + 'SELECT ''MatchCondition already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 2) 客户出口资质备案表(含 IsDefault / Remark) +-- 原:migrate_customer_export_qualification.sql +-- ----------------------------------------------------------------------------- +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_customer_export_qualification' +); + +SET @sql := IF( + @exists = 0, + 'CREATE TABLE `t_customer_export_qualification` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `CustomerId` INT NOT NULL COMMENT ''归属客户 t_customer.Id'', + `CompanyName` VARCHAR(200) NOT NULL COMMENT ''企业名称'', + `SocialCreditCode` VARCHAR(64) NOT NULL COMMENT ''企业社会信用代码'', + `DomesticConsignor` VARCHAR(200) NOT NULL COMMENT ''境内发货人'', + `DomesticConsignorCustomsCode` VARCHAR(64) NULL COMMENT ''境内发货人海关代码'', + `ProducerName` VARCHAR(200) NOT NULL COMMENT ''生产销售单位'', + `ProducerCustomsCode` VARCHAR(64) NULL COMMENT ''生产销售单位海关代码'', + `CustomsPort` VARCHAR(100) NULL COMMENT ''备案海关港口地'', + `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'', + `Remark` VARCHAR(512) NULL COMMENT ''备注'', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', + `UpdateUserId` INT NULL COMMENT ''更新人'', + `UpdateTime` DATETIME NULL COMMENT ''更新时间'', + PRIMARY KEY (`Id`), + KEY `idx_ceq_customer` (`CustomerId`, `IsDeleted`, `Id`), + KEY `idx_ceq_credit` (`SocialCreditCode`, `IsDeleted`), + KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`) +) COMMENT=''客户出口资质备案''', + 'SELECT ''t_customer_export_qualification already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_customer_export_qualification' + AND COLUMN_NAME = 'IsDefault' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_customer_export_qualification` + ADD COLUMN `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'' AFTER `CustomsPort`', + 'SELECT ''IsDefault already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_customer_export_qualification' + AND COLUMN_NAME = 'Remark' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_customer_export_qualification` + ADD COLUMN `Remark` VARCHAR(512) NULL COMMENT ''备注'' AFTER `IsDefault`', + 'SELECT ''Remark already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_customer_export_qualification' + AND INDEX_NAME = 'idx_ceq_customer_default' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE `t_customer_export_qualification` ADD KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)', + 'SELECT ''idx_ceq_customer_default already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 3) t_order:出口资质备案 Id +-- 原:migrate_order_export_qualification_id.sql +-- ----------------------------------------------------------------------------- +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order' + AND COLUMN_NAME = 'ExportQualificationId' +); + +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order` + ADD COLUMN `ExportQualificationId` INT NULL COMMENT ''出口资质备案 Id(t_customer_export_qualification.Id)'' AFTER `ExportCustomsMode`, + ADD KEY `idx_order_export_qualification` (`ExportQualificationId`)', + 'SELECT ''t_order.ExportQualificationId already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 4) SKU / 订单申报:DeclareElement +-- 原:migrate_declare_element.sql +-- ----------------------------------------------------------------------------- +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 'c_sku' + AND COLUMN_NAME = 'DeclareElement' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `c_sku` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ImHsCode`', + 'SELECT ''c_sku.DeclareElement already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_declaration' + AND COLUMN_NAME = 'DeclareElement' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order_declaration` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ExHsCode`', + 'SELECT ''t_order_declaration.DeclareElement already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 5) 问题模板:处理人字段 +-- 原:migrate_issue_template_handler.sql +-- ----------------------------------------------------------------------------- +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_issue_template' + AND COLUMN_NAME = 'HandlerType' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT ''处理人类型:customer=订单所属客户,user=指定用户'' AFTER `NotifyMethod`', + 'SELECT ''HandlerType already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_issue_template' + AND COLUMN_NAME = 'HandlerUserId' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerUserId` INT NULL COMMENT ''指定处理人用户Id(HandlerType=user 时必填)'' AFTER `HandlerType`', + 'SELECT ''HandlerUserId already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `t_issue_template` +SET `HandlerType` = 'user' +WHERE (`HandlerType` IS NULL OR `HandlerType` = '') + AND `IsDeleted` = 0; + +-- ----------------------------------------------------------------------------- +-- 6) 揽收方向 + 客户地址类型 +-- 原:migrate_pickup_direction_and_address_type.sql +-- 注意:裸 ALTER,已执行过勿再跑本段 +-- ----------------------------------------------------------------------------- +ALTER TABLE `t_pickup_order` + ADD COLUMN `Direction` VARCHAR(16) NOT NULL DEFAULT 'forward' + COMMENT 'forward正向(客户→仓)/reverse逆向(仓→客户)' + AFTER `SenderAddressId`; + +ALTER TABLE `t_pickup_order` + ADD KEY `idx_pickup_direction_status` (`Direction`, `Status`, `CreateTime`); + +ALTER TABLE `t_customer_sender_address` + MODIFY COLUMN `AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender' + COMMENT 'sender国内发件/return国内退件/pickup国内揽收/receiver海外收件'; + +-- ----------------------------------------------------------------------------- +-- 7) 揽收单:承运推送件重尺 + 费用快照 +-- 原:migrate_pickup_fee_push.sql +-- 注意:裸 ALTER,已执行过勿再跑本段 +-- ----------------------------------------------------------------------------- +ALTER TABLE `t_pickup_order` + ADD COLUMN `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重' AFTER `ChargeableWeight`, + ADD COLUMN `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON' AFTER `VolumetricWeight`, + ADD COLUMN `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)' AFTER `PackageSizesJson`, + ADD COLUMN `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)' AFTER `CarrierFreightWeight`, + ADD COLUMN `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)' AFTER `CarrierQuotedFreightAmount`, + ADD COLUMN `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文' AFTER `CarrierQuotedReceivableAmount`, + ADD COLUMN `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON' AFTER `CarrierQuoteJson`, + ADD COLUMN `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON' AFTER `ReceivableFeeJson`, + ADD COLUMN `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间' AFTER `PayableFeeJson`; + +-- ----------------------------------------------------------------------------- +-- 8) 客户 OpenAPI 机机凭证 +-- 原:migrate_customer_openapi_credential.sql +-- ----------------------------------------------------------------------------- +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_customer_openapi_credential' +); + +SET @sql := IF( + @exists = 0, + 'CREATE TABLE `t_customer_openapi_credential` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `CustomerId` INT NOT NULL COMMENT ''归属主客户 t_customer.Id'', + `AppKey` VARCHAR(64) NOT NULL COMMENT ''开放接口 AppKey'', + `AppSecret` VARCHAR(128) NOT NULL COMMENT ''开放接口 AppSecret(明文,创建/重置时返回)'', + `Enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:0-否,1-是'', + `LastUsedAt` DATETIME NULL COMMENT ''最近鉴权成功时间'', + `LastUsedIp` VARCHAR(64) NULL COMMENT ''最近鉴权成功 IP'', + `Remark` VARCHAR(512) NULL COMMENT ''备注'', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', + `UpdateUserId` INT NULL COMMENT ''更新人'', + `UpdateTime` DATETIME NULL COMMENT ''更新时间'', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_coc_customer` (`CustomerId`), + UNIQUE KEY `uk_coc_app_key` (`AppKey`), + KEY `idx_coc_enabled` (`Enabled`, `IsDeleted`) +) COMMENT=''客户 OpenAPI 机机凭证''', + 'SELECT ''t_customer_openapi_credential already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 9) 分区明细:匹配维度 / 方式 / 前缀长度 +-- ----------------------------------------------------------------------------- +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_kpi_area_info' + AND COLUMN_NAME = 'MatchDim' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_kpi_area_info` + ADD COLUMN `MatchDim` VARCHAR(16) NOT NULL DEFAULT ''country'' COMMENT ''匹配维度:zip/state/city/country'' AFTER `RightPostcode`, + ADD COLUMN `MatchMode` VARCHAR(16) NOT NULL DEFAULT ''all'' COMMENT ''匹配方式:prefix/exact/range/all'' AFTER `MatchDim`, + ADD COLUMN `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT ''邮编前缀长度(zip+prefix)'' AFTER `MatchMode`', + 'SELECT ''kpi area match columns already exist'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_kpi_area_info' + AND INDEX_NAME = 'idx_kpi_info_area_dim' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE `t_kpi_area_info` ADD KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)', + 'SELECT ''idx_kpi_info_area_dim already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `t_kpi_area_info` +SET + `MatchDim` = CASE + WHEN IFNULL(`LeftPostcode`, '') <> '' OR IFNULL(`RightPostcode`, '') <> '' THEN 'zip' + WHEN IFNULL(`City`, '') <> '' THEN 'city' + WHEN IFNULL(`Province`, '') <> '' THEN 'state' + ELSE 'country' + END, + `MatchMode` = CASE + WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN 'prefix' + WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN 'exact' + WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN 'range' + WHEN IFNULL(`City`, '') <> '' OR IFNULL(`Province`, '') <> '' THEN 'exact' + ELSE 'all' + END, + `PrefixLen` = CASE + WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN CHAR_LENGTH(`LeftPostcode`) + WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN CHAR_LENGTH(`LeftPostcode`) + WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN LEAST(CHAR_LENGTH(`LeftPostcode`), CHAR_LENGTH(`RightPostcode`)) + ELSE 0 + END +WHERE IFNULL(`MatchDim`, '') IN ('', 'country') + AND ( + IFNULL(`LeftPostcode`, '') <> '' + OR IFNULL(`RightPostcode`, '') <> '' + OR IFNULL(`Province`, '') <> '' + OR IFNULL(`City`, '') <> '' + ); diff --git a/sql/migrate_kpi_area_info_match.sql b/sql/migrate_kpi_area_info_match.sql new file mode 100644 index 0000000..1096fae --- /dev/null +++ b/sql/migrate_kpi_area_info_match.sql @@ -0,0 +1,66 @@ +-- 存量库:分区明细增加匹配维度/方式/前缀长度(可重复执行) +SET @db := DATABASE(); + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_kpi_area_info' + AND COLUMN_NAME = 'MatchDim' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_kpi_area_info` + ADD COLUMN `MatchDim` VARCHAR(16) NOT NULL DEFAULT ''country'' COMMENT ''匹配维度:zip/state/city/country'' AFTER `RightPostcode`, + ADD COLUMN `MatchMode` VARCHAR(16) NOT NULL DEFAULT ''all'' COMMENT ''匹配方式:prefix/exact/range/all'' AFTER `MatchDim`, + ADD COLUMN `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT ''邮编前缀长度(zip+prefix)'' AFTER `MatchMode`', + 'SELECT ''kpi area match columns already exist'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_kpi_area_info' + AND INDEX_NAME = 'idx_kpi_info_area_dim' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE `t_kpi_area_info` ADD KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)', + 'SELECT ''idx_kpi_info_area_dim already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `t_kpi_area_info` +SET + `MatchDim` = CASE + WHEN IFNULL(`LeftPostcode`, '') <> '' OR IFNULL(`RightPostcode`, '') <> '' THEN 'zip' + WHEN IFNULL(`City`, '') <> '' THEN 'city' + WHEN IFNULL(`Province`, '') <> '' THEN 'state' + ELSE 'country' + END, + `MatchMode` = CASE + WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN 'prefix' + WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN 'exact' + WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN 'range' + WHEN IFNULL(`City`, '') <> '' OR IFNULL(`Province`, '') <> '' THEN 'exact' + ELSE 'all' + END, + `PrefixLen` = CASE + WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN CHAR_LENGTH(`LeftPostcode`) + WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN CHAR_LENGTH(`LeftPostcode`) + WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN LEAST(CHAR_LENGTH(`LeftPostcode`), CHAR_LENGTH(`RightPostcode`)) + ELSE 0 + END +WHERE IFNULL(`MatchDim`, '') IN ('', 'country') + AND ( + IFNULL(`LeftPostcode`, '') <> '' + OR IFNULL(`RightPostcode`, '') <> '' + OR IFNULL(`Province`, '') <> '' + OR IFNULL(`City`, '') <> '' + ); diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs index 2536231..efea04f 100644 --- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs +++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs @@ -45,6 +45,21 @@ public class KpiAreaInfoOutput /// public string? RightPostcode { get; set; } + /// + /// 匹配维度:zip / state / city / country。 + /// + public string? MatchDim { get; set; } + + /// + /// 匹配方式:prefix / exact / range / all。 + /// + public string? MatchMode { get; set; } + + /// + /// 邮编前缀长度(zip+prefix)。 + /// + public int PrefixLen { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs index aa8caf8..e754747 100644 --- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs +++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs @@ -44,4 +44,19 @@ public class KpiAreaInfoSaveInput /// 结束邮编。 /// public string? RightPostcode { get; set; } + + /// + /// 匹配维度:zip / state / city / country。 + /// + public string? MatchDim { get; set; } + + /// + /// 匹配方式:prefix / exact / range / all。 + /// + public string? MatchMode { get; set; } + + /// + /// 邮编前缀长度。 + /// + public int? PrefixLen { get; set; } } diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs index c45bee6..7c6b0a5 100644 --- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs @@ -55,6 +55,11 @@ public class OrderPriceCheckInput /// public string? DestinationProvince { get; set; } + /// + /// 目的城市。 + /// + public string? DestinationCity { get; set; } + /// /// 邮编。 /// diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs index feb4d90..d3c698b 100644 --- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs +++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs @@ -23,7 +23,7 @@ public class SalesProductPricePublishSnapshotDto public string? PriceAreaName { get; set; } /// - /// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。 + /// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为闭区间。 /// public List PriceAreaInfos { get; set; } = []; @@ -59,7 +59,7 @@ public class SalesProductRulesPublishSnapshotDto public string? BlacklistAreaName { get; set; } /// - /// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。 + /// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。 /// public List BlacklistAreaInfos { get; set; } = []; @@ -74,7 +74,7 @@ public class SalesProductRulesPublishSnapshotDto public string? RemoteAreaName { get; set; } /// - /// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。 + /// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。 /// public List RemoteAreaInfos { get; set; } = []; diff --git a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs index bd9ba46..eb347f2 100644 --- a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs +++ b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs @@ -17,8 +17,16 @@ public class KpiAreaSnapshotRuleDto public string? City { get; set; } + /// zip / state / city / country,发布时固化。 + public string? MatchDim { get; set; } + + /// prefix / exact / range / all,发布时固化。 + public string? MatchMode { get; set; } + + public int PrefixLen { get; set; } + /// - /// 邮编区间,左开右闭,如 (50000,90000]。 + /// 邮编匹配编码:prefix:900 / exact:90001 / [90000,90199];旧快照 (left,right] 按闭区间解析。 /// public string? PostcodeRange { get; set; } diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs index 8313e88..7c76aa1 100644 --- a/src/Oms.Application/Services/Channels/KpiAreaService.cs +++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs @@ -122,7 +122,7 @@ public class KpiAreaService : ApplicationService public async Task AddInfoAsync(KpiAreaInfoSaveInput input) { await GetAreaOrThrowAsync(input.AreaId); - await KpiAreaRepository.InsertInfoAsync(new KpiAreaInfo + var entity = new KpiAreaInfo { AreaId = input.AreaId, CountryCode = input.CountryCode, @@ -130,8 +130,13 @@ public class KpiAreaService : ApplicationService Province = input.Province, City = input.City, LeftPostcode = input.LeftPostcode, - RightPostcode = input.RightPostcode - }); + RightPostcode = input.RightPostcode, + MatchDim = input.MatchDim, + MatchMode = input.MatchMode, + PrefixLen = input.PrefixLen ?? 0 + }; + PrepareAreaInfo(entity); + await KpiAreaRepository.InsertInfoAsync(entity); } /// @@ -151,6 +156,10 @@ public class KpiAreaService : ApplicationService entity.City = input.City; entity.LeftPostcode = input.LeftPostcode; entity.RightPostcode = input.RightPostcode; + entity.MatchDim = input.MatchDim; + entity.MatchMode = input.MatchMode; + entity.PrefixLen = input.PrefixLen ?? 0; + PrepareAreaInfo(entity); await KpiAreaRepository.UpdateInfoAsync(entity); } @@ -293,8 +302,21 @@ public class KpiAreaService : ApplicationService }; } + private static void PrepareAreaInfo(KpiAreaInfo entity) + { + try + { + GeoPartitionMatcher.PrepareEntity(entity); + } + catch (InvalidOperationException ex) + { + throw new BusinessException(ex.Message); + } + } + private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity) { + var (matchDim, matchMode, prefixLen) = GeoPartitionMatcher.Describe(entity); return new KpiAreaInfoOutput { Id = entity.Id, @@ -305,6 +327,9 @@ public class KpiAreaService : ApplicationService City = entity.City, LeftPostcode = entity.LeftPostcode, RightPostcode = entity.RightPostcode, + MatchDim = matchDim, + MatchMode = matchMode, + PrefixLen = prefixLen, CreateTime = entity.CreateTime, UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) }; diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs index 28ca2c5..ed2bb5a 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs @@ -277,7 +277,7 @@ public partial class SalesProductService if (rulesSnapshot.RemoteAreaInfos is { Count: > 0 }) { - return RulesSnapshotHelper.IsAreaHitFromSnapshot( + return GeoPartitionMatcher.IsHit( rulesSnapshot.RemoteAreaInfos, countryCode, province, @@ -286,12 +286,7 @@ public partial class SalesProductService } var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); - return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( - rule, - countryCode, - province, - city, - zipCode)); + return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode); } private async Task ResolveSimulateWarehouseNameAsync(int? warehouseId) diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 993df8f..f3c00a6 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -1500,7 +1500,7 @@ public partial class OrderService if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 }) { - return RulesSnapshotHelper.IsAreaHitFromSnapshot( + return GeoPartitionMatcher.IsHit( rulesSnapshot.BlacklistAreaInfos, countryCode, province, @@ -1509,12 +1509,7 @@ public partial class OrderService } var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); - return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( - rule, - countryCode, - province, - city, - zipCode)); + return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode); } private async Task IsRemoteHitAsync( @@ -1533,7 +1528,7 @@ public partial class OrderService if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 }) { - return RulesSnapshotHelper.IsAreaHitFromSnapshot( + return GeoPartitionMatcher.IsHit( rulesSnapshot.RemoteAreaInfos, countryCode, province, @@ -1542,12 +1537,7 @@ public partial class OrderService } var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); - return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( - rule, - countryCode, - province, - city, - zipCode)); + return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode); } private static int? ResolveEffectiveBlacklistAreaId( @@ -1649,12 +1639,12 @@ public partial class OrderService } var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value); - var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched( - rule, + var matched = GeoPartitionMatcher.MatchEntity( + rules, countryCode, province, city, - zipCode)); + zipCode); if (matched == null) { return null; @@ -2998,11 +2988,6 @@ public partial class OrderService } var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(channel.RemoteAreaId.Value); - return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( - rule, - countryCode, - province, - city, - zipCode)); + return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode); } } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index a2b290a..7220889 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -135,6 +135,7 @@ public partial class OrderService { CountryCode = countryCode, Province = input.DestinationProvince, + City = input.DestinationCity, PostalCode = input.PostalCode, Length = dimensionLength, Width = dimensionWidth, @@ -229,6 +230,7 @@ public partial class OrderService { CountryCode = baseContext.CountryCode, Province = baseContext.Province, + City = baseContext.City, PostalCode = baseContext.PostalCode, CargoAttribute = cargo.CargoAttribute, CargoTypeCode = cargo.Code, @@ -531,8 +533,7 @@ public partial class OrderService return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName); } - if (profile.HasPostalCode - && await IsBlacklistHitAsync( + if (await IsBlacklistHitAsync( product, snapshotBinding.Rules, context.CountryCode, @@ -588,8 +589,7 @@ public partial class OrderService ParcelQuoteResult? bestQuote = null; decimal bestTotal = decimal.MaxValue; - var isRemote = profile.HasPostalCode - && await IsRemoteHitAsync( + var isRemote = await IsRemoteHitAsync( product, snapshotBinding.Rules, context.CountryCode, @@ -705,7 +705,7 @@ public partial class OrderService attempts.Add(new QuoteZoneAttempt(keys, isFallback, keys[0])); } - if (profile.HasPostalCode || profile.HasProvince) + if (profile.HasPostalCode || profile.HasProvince || profile.HasCity) { await TryAddAsync( profile.HasProvince ? context.Province : null, diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 331c327..0858b86 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -87,41 +87,8 @@ internal static class ParcelBillingCalculator string countryCode, string? province, string? city, - string? zipCode) - { - if (!string.IsNullOrWhiteSpace(rule.CountryCode) - && !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(rule.Province) - && !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(rule.City) - && !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(rule.LeftPostcode) || !string.IsNullOrWhiteSpace(rule.RightPostcode)) - { - if (string.IsNullOrWhiteSpace(zipCode)) - { - return false; - } - - if (!IsPostcodeInRange(zipCode, rule.LeftPostcode, rule.RightPostcode)) - { - return false; - } - } - - return true; - } + string? zipCode) => + GeoPartitionMatcher.IsHit([rule], countryCode, province, city, zipCode); public static bool IsAreaMatched( KpiAreaInfoOutput rule, @@ -146,8 +113,12 @@ internal static class ParcelBillingCalculator return null; } - var matched = priceSnapshot.PriceAreaInfos.FirstOrDefault(rule => - KpiAreaSnapshotMapper.IsRuleMatched(rule, countryCode, province, city, zipCode)); + var matched = GeoPartitionMatcher.MatchSnapshot( + priceSnapshot.PriceAreaInfos, + countryCode, + province, + city, + zipCode); if (matched == null) { return null; @@ -2203,24 +2174,6 @@ internal static class ParcelBillingCalculator } } - private static bool IsPostcodeInRange(string zipCode, string? left, string? right) - { - if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right)) - { - return true; - } - - if (long.TryParse(zipCode, out var zip) - && long.TryParse(left, out var leftValue) - && long.TryParse(right, out var rightValue)) - { - return zip >= leftValue && zip <= rightValue; - } - - return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) >= 0 - && string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0; - } - private sealed class ContinuePricingConfig { public decimal FirstWeight { get; set; } diff --git a/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs b/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs index 8d489db..1cc1540 100644 --- a/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs +++ b/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs @@ -56,9 +56,11 @@ internal static class PriceCheckHintBuilder PriceCheckMissingFieldCodes.Dimensions => "未填写包裹尺寸:计费重按实重估算,入库实测尺寸后价格可能变化。", PriceCheckMissingFieldCodes.PostalCode => - "未填写邮编:未校验黑名单/偏远/精确分区,报价仅供参考。", + "未填写邮编:优先按州/市或国家级分区匹配,精确邮编可能调整价格。", PriceCheckMissingFieldCodes.Province => - "未填写省州:分区按国家级匹配,精确地址可能调整价格。", + "未填写省州:无邮编时无法按州/市降级匹配,分区可能按国家级兜底。", + PriceCheckMissingFieldCodes.City => + "未填写城市:无邮编且州未命中时无法按城市匹配。", PriceCheckMissingFieldCodes.CargoType => "未选择货物属性:已分别试算普货/带电/敏感货等,下单时以所选属性为准。", PriceCheckMissingFieldCodes.Warehouse => diff --git a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs index fd72af7..895f8c3 100644 --- a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs +++ b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs @@ -13,6 +13,8 @@ internal sealed class PriceCheckInputProfile public bool HasProvince { get; init; } + public bool HasCity { get; init; } + public bool HasCargoTypeFilter { get; init; } public bool HasWarehouseFilter { get; init; } @@ -26,6 +28,7 @@ internal sealed class PriceCheckInputProfile var hasDimensions = input.Length is > 0 && input.Width is > 0 && input.Height is > 0; var hasPostal = !string.IsNullOrWhiteSpace(input.PostalCode); var hasProvince = !string.IsNullOrWhiteSpace(input.DestinationProvince); + var hasCity = !string.IsNullOrWhiteSpace(input.DestinationCity); var cargoCodes = (input.CargoTypes ?? []) .Where(x => !string.IsNullOrWhiteSpace(x)) .ToList(); @@ -69,6 +72,7 @@ internal sealed class PriceCheckInputProfile HasDimensions = hasDimensions, HasPostalCode = hasPostal, HasProvince = hasProvince, + HasCity = hasCity, HasCargoTypeFilter = hasCargoFilter, HasWarehouseFilter = hasWarehouseFilter, HasShippingMethodFilter = hasShippingFilter, @@ -87,6 +91,8 @@ internal static class PriceCheckMissingFieldCodes public const string Province = "province"; + public const string City = "city"; + public const string CargoType = "cargoType"; public const string Warehouse = "warehouse"; diff --git a/src/Oms.Application/Shared/GeoPartitionMatcher.cs b/src/Oms.Application/Shared/GeoPartitionMatcher.cs new file mode 100644 index 0000000..1aaae50 --- /dev/null +++ b/src/Oms.Application/Shared/GeoPartitionMatcher.cs @@ -0,0 +1,751 @@ +using System.Text.RegularExpressions; +using Oms.Application.Dtos.Orders; +using Oms.Domain.ChannelsAggregate.Entities; + +namespace Oms.Application.Shared; + +/// +/// 地理分区规则(价格/黑名单/偏远共用)。 +/// +public sealed class GeoRule +{ + public int Ordinal { get; init; } + + public object? Tag { get; init; } + + public string CountryCode { get; init; } = string.Empty; + + /// zip / state / city / country + public string MatchDim { get; init; } = string.Empty; + + /// prefix / exact / range / all + public string MatchMode { get; init; } = string.Empty; + + public int PrefixLen { get; init; } + + public string MatchTextStd { get; init; } = string.Empty; + + public string ZipStartStd { get; init; } = string.Empty; + + public string ZipEndStd { get; init; } = string.Empty; + + public string MatchStateStd { get; init; } = string.Empty; +} + +/// +/// 地址清洗、规则编译、单套分区匹配。价格/黑名单/偏远分区共用。 +/// +public static class GeoPartitionMatcher +{ + public const string DimZip = "zip"; + public const string DimState = "state"; + public const string DimCity = "city"; + public const string DimCountry = "country"; + + public const string ModePrefix = "prefix"; + public const string ModeExact = "exact"; + public const string ModeRange = "range"; + public const string ModeAll = "all"; + + private static readonly Regex PostcodeRangePattern = new( + @"^[\[(]\s*([^,)\]]*)\s*,\s*([^,)\]]*)\s*[\])]$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static string NormalizeCountry(string? value) => + string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); + + public static string NormalizeZip(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var chars = value.Where(ch => !char.IsWhiteSpace(ch) && ch != '-').ToArray(); + return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant(); + } + + public static string NormalizeRegion(string? value) => + string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); + + /// + /// 执行单套地理分区匹配。调用方需传入已排序规则:zip > state > city > country,同维度 prefixLen 降序。 + /// + public static GeoRule? MatchSinglePartition( + string countryCode, + string zipStd, + string stateStd, + string cityStd, + IReadOnlyList ruleList) + { + var country = NormalizeCountry(countryCode); + zipStd ??= string.Empty; + stateStd ??= string.Empty; + cityStd ??= string.Empty; + if (ruleList == null || ruleList.Count == 0 || string.IsNullOrEmpty(country)) + { + return null; + } + + foreach (var rule in ruleList) + { + if (!string.Equals(rule.CountryCode, country, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (rule.MatchDim == DimZip) + { + if (string.IsNullOrEmpty(zipStd)) + { + continue; + } + + var hit = false; + if (rule.MatchMode == ModePrefix) + { + if (rule.PrefixLen > 0 && zipStd.Length >= rule.PrefixLen) + { + var userPrefix = zipStd[..rule.PrefixLen]; + hit = userPrefix == rule.MatchTextStd; + } + } + else if (rule.MatchMode == ModeExact) + { + hit = zipStd == rule.MatchTextStd; + } + else if (rule.MatchMode == ModeRange) + { + hit = zipStd.CompareTo(rule.ZipStartStd) >= 0 + && zipStd.CompareTo(rule.ZipEndStd) <= 0; + } + + if (hit) + { + return rule; + } + } + else if (rule.MatchDim == DimState) + { + if (!string.IsNullOrEmpty(zipStd)) + { + continue; + } + + if (rule.MatchMode == ModeExact && stateStd == rule.MatchTextStd && !string.IsNullOrEmpty(stateStd)) + { + return rule; + } + } + else if (rule.MatchDim == DimCity) + { + if (!string.IsNullOrEmpty(zipStd)) + { + continue; + } + + if (rule.MatchMode == ModeExact + && rule.MatchStateStd == stateStd + && rule.MatchTextStd == cityStd + && !string.IsNullOrEmpty(stateStd) + && !string.IsNullOrEmpty(cityStd)) + { + return rule; + } + } + else if (rule.MatchDim == DimCountry) + { + return rule; + } + } + + return null; + } + + public static IReadOnlyList SortRules(IEnumerable rules) => + rules + .OrderBy(DimRank) + .ThenByDescending(r => r.PrefixLen) + .ThenByDescending(RangeSpecificity) + .ThenBy(r => r.Ordinal) + .ToList(); + + public static GeoRule? Compile(KpiAreaInfo entity, int ordinal = 0, bool attachTag = true) + { + if (entity == null) + { + return null; + } + + return Compile( + entity.CountryCode, + entity.Province, + entity.City, + entity.LeftPostcode, + entity.RightPostcode, + null, + entity.MatchDim, + entity.MatchMode, + entity.PrefixLen, + ordinal, + attachTag ? entity : null); + } + + public static GeoRule? Compile(KpiAreaSnapshotRuleDto rule, int ordinal = 0, bool attachTag = true) + { + if (rule == null) + { + return null; + } + + var range = KpiAreaSnapshotMapper.ResolvePostcodeRange(rule); + return Compile( + rule.CountryCode, + rule.Province, + rule.City, + rule.LeftPostcode, + rule.RightPostcode, + range, + rule.MatchDim, + rule.MatchMode, + rule.PrefixLen, + ordinal, + attachTag ? rule : null); + } + + public static KpiAreaSnapshotRuleDto? MatchSnapshot( + IEnumerable? infos, + string countryCode, + string? province, + string? city, + string? zipCode) + { + var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode); + return hit?.Tag as KpiAreaSnapshotRuleDto; + } + + public static KpiAreaInfo? MatchEntity( + IEnumerable? infos, + string countryCode, + string? province, + string? city, + string? zipCode) + { + var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode); + return hit?.Tag as KpiAreaInfo; + } + + public static bool IsHit( + IEnumerable? infos, + string countryCode, + string? province, + string? city, + string? zipCode) => + MatchEntity(infos, countryCode, province, city, zipCode) != null; + + public static bool IsHit( + IEnumerable? infos, + string countryCode, + string? province, + string? city, + string? zipCode) => + MatchSnapshot(infos, countryCode, province, city, zipCode) != null; + + public static GeoRule? Match( + IReadOnlyList compiled, + string countryCode, + string? province, + string? city, + string? zipCode) + { + var sorted = SortRules(compiled); + return MatchSinglePartition( + NormalizeCountry(countryCode), + NormalizeZip(zipCode), + NormalizeRegion(province), + NormalizeRegion(city), + sorted); + } + + /// + /// 清洗并收口为一维规则,写入 MatchDim / MatchMode / PrefixLen。 + /// 显式维度优先;未传则按字段推断。 + /// + public static void PrepareEntity(KpiAreaInfo entity) + { + ArgumentNullException.ThrowIfNull(entity); + entity.CountryCode = string.IsNullOrWhiteSpace(entity.CountryCode) ? null : entity.CountryCode.Trim(); + entity.CountryName = string.IsNullOrWhiteSpace(entity.CountryName) ? null : entity.CountryName.Trim(); + entity.Province = EmptyToNull(entity.Province); + entity.City = EmptyToNull(entity.City); + entity.LeftPostcode = EmptyToNull(entity.LeftPostcode); + entity.RightPostcode = EmptyToNull(entity.RightPostcode); + + if (string.IsNullOrWhiteSpace(entity.CountryCode)) + { + throw new InvalidOperationException("国家代码不能为空"); + } + + var dim = NormalizeDim(entity.MatchDim); + var mode = NormalizeMode(entity.MatchMode); + if (string.IsNullOrEmpty(dim)) + { + dim = InferDim(entity.LeftPostcode, entity.RightPostcode, entity.Province, entity.City); + } + + switch (dim) + { + case DimZip: + ApplyZipFields(entity, mode); + break; + case DimState: + if (string.IsNullOrWhiteSpace(entity.Province)) + { + throw new InvalidOperationException("州/省匹配必须填写州/省"); + } + + entity.City = null; + entity.LeftPostcode = null; + entity.RightPostcode = null; + entity.MatchDim = DimState; + entity.MatchMode = ModeExact; + entity.PrefixLen = 0; + break; + case DimCity: + if (string.IsNullOrWhiteSpace(entity.Province) || string.IsNullOrWhiteSpace(entity.City)) + { + throw new InvalidOperationException("城市匹配必须同时填写州/省"); + } + + entity.LeftPostcode = null; + entity.RightPostcode = null; + entity.MatchDim = DimCity; + entity.MatchMode = ModeExact; + entity.PrefixLen = 0; + break; + default: + entity.Province = null; + entity.City = null; + entity.LeftPostcode = null; + entity.RightPostcode = null; + entity.MatchDim = DimCountry; + entity.MatchMode = ModeAll; + entity.PrefixLen = 0; + break; + } + } + + public static (string MatchDim, string MatchMode, int PrefixLen) Describe(KpiAreaInfo? entity) + { + if (entity == null) + { + return (DimCountry, ModeAll, 0); + } + + if (!string.IsNullOrWhiteSpace(entity.MatchDim)) + { + return ( + NormalizeDim(entity.MatchDim) ?? DimCountry, + NormalizeMode(entity.MatchMode) ?? ModeAll, + entity.PrefixLen); + } + + var rule = Compile(entity, attachTag: false); + if (rule == null) + { + return (DimCountry, ModeAll, 0); + } + + return (rule.MatchDim, rule.MatchMode, rule.PrefixLen); + } + + private static GeoRule? Compile( + string? countryCode, + string? province, + string? city, + string? leftPostcode, + string? rightPostcode, + string? postcodeRange, + string? matchDim, + string? matchMode, + int prefixLenHint, + int ordinal, + object? tag) + { + var country = NormalizeCountry(countryCode); + if (string.IsNullOrEmpty(country)) + { + return null; + } + + var dim = NormalizeDim(matchDim); + if (string.IsNullOrEmpty(dim)) + { + dim = InferDim(leftPostcode, rightPostcode, province, city, postcodeRange); + } + + var encoded = TryParseEncodedRange(postcodeRange); + var zipLeft = NormalizeZip(leftPostcode); + var zipRight = NormalizeZip(rightPostcode); + var zipMode = NormalizeMode(matchMode) ?? encoded?.Mode; + var zipText = encoded?.Text ?? string.Empty; + var zipStart = encoded?.Start ?? string.Empty; + var zipEnd = encoded?.End ?? string.Empty; + var prefixLen = prefixLenHint > 0 ? prefixLenHint : encoded?.PrefixLen ?? 0; + + if (zipMode is not (ModePrefix or ModeExact or ModeRange)) + { + zipMode = encoded?.Mode; + } + + if (zipMode == null) + { + if (zipLeft.Length > 0 && zipRight.Length == 0) + { + zipMode = ModePrefix; + zipText = zipLeft; + prefixLen = zipLeft.Length; + } + else if (zipLeft.Length > 0 && zipLeft == zipRight) + { + zipMode = ModeExact; + zipText = zipLeft; + prefixLen = zipLeft.Length; + } + else if (zipLeft.Length > 0 && zipRight.Length > 0) + { + zipMode = ModeRange; + zipStart = zipLeft; + zipEnd = zipRight; + prefixLen = Math.Min(zipLeft.Length, zipRight.Length); + } + } + else if (dim == DimZip) + { + if (zipMode == ModePrefix) + { + zipText = zipLeft.Length > 0 ? zipLeft : zipText; + prefixLen = prefixLen > 0 ? prefixLen : zipText.Length; + } + else if (zipMode == ModeExact) + { + zipText = zipLeft.Length > 0 ? zipLeft : zipText; + prefixLen = zipText.Length; + } + else if (zipMode == ModeRange) + { + zipStart = zipLeft.Length > 0 ? zipLeft : zipStart; + zipEnd = zipRight.Length > 0 ? zipRight : zipEnd; + prefixLen = Math.Min( + zipStart.Length == 0 ? prefixLen : zipStart.Length, + zipEnd.Length == 0 ? prefixLen : zipEnd.Length); + } + } + + if (dim == DimZip) + { + if (zipMode == null) + { + return null; + } + + return new GeoRule + { + Ordinal = ordinal, + Tag = tag, + CountryCode = country, + MatchDim = DimZip, + MatchMode = zipMode, + PrefixLen = prefixLen, + MatchTextStd = zipText, + ZipStartStd = zipStart, + ZipEndStd = zipEnd + }; + } + + var state = NormalizeRegion(province); + var cityStd = NormalizeRegion(city); + if (dim == DimCity) + { + if (string.IsNullOrEmpty(state) || string.IsNullOrEmpty(cityStd)) + { + return null; + } + + return new GeoRule + { + Ordinal = ordinal, + Tag = tag, + CountryCode = country, + MatchDim = DimCity, + MatchMode = ModeExact, + MatchTextStd = cityStd, + MatchStateStd = state + }; + } + + if (dim == DimState) + { + if (string.IsNullOrEmpty(state)) + { + return null; + } + + return new GeoRule + { + Ordinal = ordinal, + Tag = tag, + CountryCode = country, + MatchDim = DimState, + MatchMode = ModeExact, + MatchTextStd = state + }; + } + + return new GeoRule + { + Ordinal = ordinal, + Tag = tag, + CountryCode = country, + MatchDim = DimCountry, + MatchMode = ModeAll + }; + } + + private static void ApplyZipFields(KpiAreaInfo entity, string? requestedMode) + { + entity.Province = null; + entity.City = null; + var zipLeft = NormalizeZip(entity.LeftPostcode); + var zipRight = NormalizeZip(entity.RightPostcode); + if (zipLeft.Length == 0 && zipRight.Length > 0) + { + zipLeft = zipRight; + zipRight = string.Empty; + } + + if (zipLeft.Length == 0) + { + throw new InvalidOperationException("邮编匹配必须填写起始邮编"); + } + + var mode = requestedMode; + if (mode is not (ModePrefix or ModeExact or ModeRange)) + { + if (zipRight.Length == 0) + { + mode = ModePrefix; + } + else if (zipLeft == zipRight) + { + mode = ModeExact; + } + else + { + mode = ModeRange; + } + } + + if (mode == ModePrefix) + { + entity.LeftPostcode = zipLeft; + entity.RightPostcode = null; + entity.PrefixLen = zipLeft.Length; + } + else if (mode == ModeExact) + { + entity.LeftPostcode = zipLeft; + entity.RightPostcode = zipLeft; + entity.PrefixLen = zipLeft.Length; + } + else + { + if (zipRight.Length == 0) + { + throw new InvalidOperationException("邮编区间必须填写截止邮编"); + } + + if (zipLeft.CompareTo(zipRight) > 0) + { + throw new InvalidOperationException("起始邮编不能大于截止邮编"); + } + + entity.LeftPostcode = zipLeft; + entity.RightPostcode = zipRight; + entity.PrefixLen = Math.Min(zipLeft.Length, zipRight.Length); + } + + entity.MatchDim = DimZip; + entity.MatchMode = mode; + } + + private static string InferDim( + string? leftPostcode, + string? rightPostcode, + string? province, + string? city, + string? postcodeRange = null) + { + if (!string.IsNullOrWhiteSpace(postcodeRange) + || !string.IsNullOrWhiteSpace(leftPostcode) + || !string.IsNullOrWhiteSpace(rightPostcode)) + { + return DimZip; + } + + if (!string.IsNullOrWhiteSpace(city)) + { + return DimCity; + } + + if (!string.IsNullOrWhiteSpace(province)) + { + return DimState; + } + + return DimCountry; + } + + private static string? NormalizeDim(string? value) + { + var raw = value?.Trim().ToLowerInvariant(); + return raw switch + { + DimZip or "postcode" or "postal" or "邮编" => DimZip, + DimState or "province" or "州" or "州/省" or "省" => DimState, + DimCity or "城市" => DimCity, + DimCountry or "国家" or "仅国家" => DimCountry, + _ => null + }; + } + + private static string? NormalizeMode(string? value) + { + var raw = value?.Trim().ToLowerInvariant(); + return raw switch + { + ModePrefix or "前缀" => ModePrefix, + ModeExact or "精确" or "exactmatch" => ModeExact, + ModeRange or "区间" => ModeRange, + ModeAll or "全部" => ModeAll, + _ => null + }; + } + + private static EncodedZip? TryParseEncodedRange(string? postcodeRange) + { + if (string.IsNullOrWhiteSpace(postcodeRange)) + { + return null; + } + + var raw = postcodeRange.Trim(); + if (raw.StartsWith("prefix:", StringComparison.OrdinalIgnoreCase)) + { + var text = NormalizeZip(raw["prefix:".Length..]); + if (text.Length == 0) + { + return null; + } + + return new EncodedZip(ModePrefix, text, string.Empty, string.Empty, text.Length); + } + + if (raw.StartsWith("exact:", StringComparison.OrdinalIgnoreCase)) + { + var text = NormalizeZip(raw["exact:".Length..]); + if (text.Length == 0) + { + return null; + } + + return new EncodedZip(ModeExact, text, string.Empty, string.Empty, text.Length); + } + + var match = PostcodeRangePattern.Match(raw); + if (!match.Success) + { + return null; + } + + var left = NormalizeZip(match.Groups[1].Value); + var right = NormalizeZip(match.Groups[2].Value); + if (left.Length == 0 && right.Length == 0) + { + return null; + } + + if (left.Length == 0) + { + left = right; + } + + if (right.Length == 0) + { + return new EncodedZip(ModePrefix, left, string.Empty, string.Empty, left.Length); + } + + if (left == right) + { + return new EncodedZip(ModeExact, left, string.Empty, string.Empty, left.Length); + } + + return new EncodedZip(ModeRange, string.Empty, left, right, Math.Min(left.Length, right.Length)); + } + + private static List CompileList(IEnumerable? sources, Func compile) + { + var list = new List(); + if (sources == null) + { + return list; + } + + var ordinal = 0; + foreach (var source in sources) + { + var rule = compile(source, ordinal++, true); + if (rule != null) + { + list.Add(rule); + } + } + + return list; + } + + private static int DimRank(GeoRule rule) => + rule.MatchDim switch + { + DimZip => 1, + DimState => 2, + DimCity => 3, + DimCountry => 4, + _ => 9 + }; + + private static int RangeSpecificity(GeoRule rule) + { + if (rule.MatchDim != DimZip) + { + return 0; + } + + if (rule.MatchMode == ModeExact) + { + return rule.MatchTextStd.Length * 10; + } + + if (rule.MatchMode == ModeRange) + { + return Math.Min(rule.ZipStartStd.Length, rule.ZipEndStd.Length); + } + + return 0; + } + + private static string? EmptyToNull(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private sealed record EncodedZip(string Mode, string Text, string Start, string End, int PrefixLen); +} diff --git a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs index 7377412..2ebfabf 100644 --- a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs +++ b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs @@ -36,6 +36,14 @@ public static class KpiAreaInfoImportHelper ["RightPostcode"] = nameof(KpiAreaInfo.RightPostcode), ["rightPostcode"] = nameof(KpiAreaInfo.RightPostcode), ["rightPostCode"] = nameof(KpiAreaInfo.RightPostcode), + ["匹配维度"] = nameof(KpiAreaInfo.MatchDim), + ["MatchDim"] = nameof(KpiAreaInfo.MatchDim), + ["matchDim"] = nameof(KpiAreaInfo.MatchDim), + ["匹配方式"] = nameof(KpiAreaInfo.MatchMode), + ["MatchMode"] = nameof(KpiAreaInfo.MatchMode), + ["matchMode"] = nameof(KpiAreaInfo.MatchMode), + ["前缀长度"] = nameof(KpiAreaInfo.PrefixLen), + ["PrefixLen"] = nameof(KpiAreaInfo.PrefixLen), }; public static async Task BuildTemplateBytesAsync() @@ -46,10 +54,34 @@ public static class KpiAreaInfoImportHelper { ["国家代码"] = "US", ["国家名称"] = "美国", - ["州/省"] = "CA", + ["匹配维度"] = "zip", + ["匹配方式"] = "range", + ["州/省"] = "", ["城市"] = "", ["起始邮编"] = "90000", ["截止邮编"] = "90199" + }, + new() + { + ["国家代码"] = "US", + ["国家名称"] = "美国", + ["匹配维度"] = "zip", + ["匹配方式"] = "prefix", + ["州/省"] = "", + ["城市"] = "", + ["起始邮编"] = "90", + ["截止邮编"] = "" + }, + new() + { + ["国家代码"] = "US", + ["国家名称"] = "美国", + ["匹配维度"] = "state", + ["匹配方式"] = "exact", + ["州/省"] = "CA", + ["城市"] = "", + ["起始邮编"] = "", + ["截止邮编"] = "" } }; @@ -134,7 +166,12 @@ public static class KpiAreaInfoImportHelper Province = mapped.GetValueOrDefault(nameof(KpiAreaInfo.Province)), City = mapped.GetValueOrDefault(nameof(KpiAreaInfo.City)), LeftPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.LeftPostcode)), - RightPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.RightPostcode)) + RightPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.RightPostcode)), + MatchDim = mapped.GetValueOrDefault(nameof(KpiAreaInfo.MatchDim)), + MatchMode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.MatchMode)), + PrefixLen = int.TryParse(mapped.GetValueOrDefault(nameof(KpiAreaInfo.PrefixLen)), out var prefixLen) + ? prefixLen + : 0 }; } @@ -177,13 +214,7 @@ public static class KpiAreaInfoImportHelper ValidatePostcode(entity.LeftPostcode, "起始邮编"); ValidatePostcode(entity.RightPostcode, "截止邮编"); - - var hasLeft = !string.IsNullOrWhiteSpace(entity.LeftPostcode); - var hasRight = !string.IsNullOrWhiteSpace(entity.RightPostcode); - if (hasLeft != hasRight) - { - throw new InvalidOperationException("起始邮编与截止邮编需同时填写或同时留空"); - } + GeoPartitionMatcher.PrepareEntity(entity); } private static void ValidatePostcode(string? value, string label) diff --git a/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs b/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs index 166cf03..8644700 100644 --- a/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs +++ b/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs @@ -1,18 +1,13 @@ -using System.Text.RegularExpressions; using Oms.Application.Dtos.Orders; using Oms.Domain.ChannelsAggregate.Entities; namespace Oms.Application.Shared; /// -/// KPI 分区快照映射与左开右闭邮编匹配。 +/// KPI 分区快照映射。邮编区间为闭区间,prefix/exact/range 编码进 PostcodeRange。 /// public static class KpiAreaSnapshotMapper { - private static readonly Regex PostcodeRangePattern = new( - @"^\(\s*([^,)\]]*)\s*,\s*([^,)\]]*)\s*\]$", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - public static KpiAreaSnapshotRuleDto MapToSnapshotRule(KpiAreaInfo entity) => new() { @@ -20,17 +15,37 @@ public static class KpiAreaSnapshotMapper CountryCode = entity.CountryCode, Province = entity.Province, City = entity.City, + MatchDim = entity.MatchDim, + MatchMode = entity.MatchMode, + PrefixLen = entity.PrefixLen, PostcodeRange = BuildPostcodeRange(entity.LeftPostcode, entity.RightPostcode) }; public static string? BuildPostcodeRange(string? left, string? right) { - if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right)) + var zipLeft = GeoPartitionMatcher.NormalizeZip(left); + var zipRight = GeoPartitionMatcher.NormalizeZip(right); + if (zipLeft.Length == 0 && zipRight.Length == 0) { return null; } - return $"({left?.Trim()},{right?.Trim()}]"; + if (zipLeft.Length > 0 && zipRight.Length == 0) + { + return $"prefix:{zipLeft}"; + } + + if (zipLeft.Length == 0 && zipRight.Length > 0) + { + return $"prefix:{zipRight}"; + } + + if (zipLeft == zipRight) + { + return $"exact:{zipLeft}"; + } + + return $"[{zipLeft},{zipRight}]"; } public static string? ResolvePostcodeRange(KpiAreaSnapshotRuleDto rule) @@ -48,58 +63,6 @@ public static class KpiAreaSnapshotMapper string countryCode, string? province, string? city, - string? zipCode) - { - if (!string.IsNullOrWhiteSpace(rule.CountryCode) - && !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(rule.Province) - && !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(rule.City) - && !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var postcodeRange = ResolvePostcodeRange(rule); - if (string.IsNullOrWhiteSpace(postcodeRange)) - { - return true; - } - - if (string.IsNullOrWhiteSpace(zipCode)) - { - return false; - } - - return IsZipInLeftOpenRightClosedRange(zipCode.Trim(), postcodeRange); - } - - public static bool IsZipInLeftOpenRightClosedRange(string zipCode, string postcodeRange) - { - var match = PostcodeRangePattern.Match(postcodeRange.Trim()); - if (!match.Success) - { - return false; - } - - var left = match.Groups[1].Value.Trim(); - var right = match.Groups[2].Value.Trim(); - if (long.TryParse(zipCode, out var zip) - && long.TryParse(left, out var leftValue) - && long.TryParse(right, out var rightValue)) - { - return zip > leftValue && zip <= rightValue; - } - - return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) > 0 - && string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0; - } + string? zipCode) => + GeoPartitionMatcher.IsHit([rule], countryCode, province, city, zipCode); } diff --git a/src/Oms.Application/Shared/RulesSnapshotHelper.cs b/src/Oms.Application/Shared/RulesSnapshotHelper.cs index fc5744c..88f9062 100644 --- a/src/Oms.Application/Shared/RulesSnapshotHelper.cs +++ b/src/Oms.Application/Shared/RulesSnapshotHelper.cs @@ -64,12 +64,7 @@ public static class RulesSnapshotHelper return false; } - return infos.Any(rule => KpiAreaSnapshotMapper.IsRuleMatched( - rule, - countryCode, - province, - city, - zipCode)); + return GeoPartitionMatcher.IsHit(infos, countryCode, province, city, zipCode); } /// diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs b/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs index 3c5d130..0e016fa 100644 --- a/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs +++ b/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs @@ -19,4 +19,12 @@ public class KpiAreaInfo : OmsAuditedEntity public string? LeftPostcode { get; set; } public string? RightPostcode { get; set; } + + /// zip / state / city / country + public string? MatchDim { get; set; } + + /// prefix / exact / range / all + public string? MatchMode { get; set; } + + public int PrefixLen { get; set; } } diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs new file mode 100644 index 0000000..567998a --- /dev/null +++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs @@ -0,0 +1,145 @@ +using FluentAssertions; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Entities; + +namespace Oms.Tests; + +public class GeoPartitionMatcherTests +{ + [Fact] + public void ZipPrefix_LongerPrefixWins() + { + var rules = CompileEntities( + Zip("US", "9"), + Zip("US", "90")); + + var hit = GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LA", "90001"); + hit!.LeftPostcode.Should().Be("90"); + } + + [Fact] + public void ZipPresent_ShouldSkipStateAndCity() + { + var rules = CompileEntities( + State("US", "CA"), + City("US", "CA", "LA")); + + GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LA", "90001").Should().BeNull(); + } + + [Fact] + public void ZipEmpty_StateExact_ShouldHit() + { + var rules = CompileEntities( + Zip("US", "90"), + State("US", "CA")); + + var hit = GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LA", null); + hit!.Province.Should().Be("CA"); + hit.LeftPostcode.Should().BeNull(); + } + + [Fact] + public void ZipEmpty_CityRequiresState() + { + var rules = CompileEntities(City("US", "CA", "LOSANGELES")); + + GeoPartitionMatcher.MatchEntity(rules, "US", "NY", "LOSANGELES", null).Should().BeNull(); + GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LOSANGELES", null).Should().NotBeNull(); + } + + [Fact] + public void ZipRange_ShouldBeClosedInterval() + { + var rules = CompileEntities(Range("US", "90000", "90199")); + + GeoPartitionMatcher.IsHit(rules, "US", null, null, "90000").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "90199").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "90200").Should().BeFalse(); + } + + [Fact] + public void CountryFallback_ShouldHitWhenZipMisses() + { + var rules = CompileEntities( + Range("US", "10000", "19999"), + CountryOnly("US")); + + var hit = GeoPartitionMatcher.MatchEntity(rules, "US", "CA", null, "90001"); + hit!.LeftPostcode.Should().BeNull(); + hit.Province.Should().BeNull(); + } + + [Fact] + public void ThreePartitions_ShouldNotInterfere() + { + var black = CompileEntities(Range("US", "90000", "90010")); + var remote = CompileEntities(Range("US", "05999", "06999")); + var price = CompileEntities(Zip("US", "90")); + + GeoPartitionMatcher.IsHit(black, "US", null, null, "90005").Should().BeTrue(); + GeoPartitionMatcher.IsHit(remote, "US", null, null, "90005").Should().BeFalse(); + GeoPartitionMatcher.MatchEntity(price, "US", null, null, "90005")!.LeftPostcode.Should().Be("90"); + } + + [Fact] + public void SnapshotLegacyLeftOpenFormat_ShouldParseAsClosed() + { + var infos = new List + { + new() { Id = 1, CountryCode = "US", PostcodeRange = "(00000,50000]" } + }; + + GeoPartitionMatcher.IsHit(infos, "US", null, null, "00000").Should().BeTrue(); + GeoPartitionMatcher.IsHit(infos, "US", null, null, "50000").Should().BeTrue(); + GeoPartitionMatcher.IsHit(infos, "US", null, null, "50001").Should().BeFalse(); + } + + [Fact] + public void PrepareEntity_PrefixKeepsRightEmpty() + { + var entity = new KpiAreaInfo { CountryCode = "us", LeftPostcode = "90" }; + GeoPartitionMatcher.PrepareEntity(entity); + entity.LeftPostcode.Should().Be("90"); + entity.RightPostcode.Should().BeNull(); + entity.MatchDim.Should().Be("zip"); + entity.MatchMode.Should().Be("prefix"); + entity.PrefixLen.Should().Be(2); + GeoPartitionMatcher.Describe(entity).MatchDim.Should().Be("zip"); + GeoPartitionMatcher.Describe(entity).MatchMode.Should().Be("prefix"); + } + + [Fact] + public void PrepareEntity_CityWithoutState_ShouldThrow() + { + var entity = new KpiAreaInfo { CountryCode = "US", City = "LA" }; + var act = () => GeoPartitionMatcher.PrepareEntity(entity); + act.Should().Throw().WithMessage("*州*"); + } + + private static List CompileEntities(params KpiAreaInfo[] items) + { + foreach (var item in items) + { + GeoPartitionMatcher.PrepareEntity(item); + } + + return items.ToList(); + } + + private static KpiAreaInfo Zip(string country, string prefix) => + new() { CountryCode = country, LeftPostcode = prefix }; + + private static KpiAreaInfo Range(string country, string left, string right) => + new() { CountryCode = country, LeftPostcode = left, RightPostcode = right }; + + private static KpiAreaInfo State(string country, string state) => + new() { CountryCode = country, Province = state }; + + private static KpiAreaInfo City(string country, string state, string city) => + new() { CountryCode = country, Province = state, City = city }; + + private static KpiAreaInfo CountryOnly(string country) => + new() { CountryCode = country }; +} diff --git a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs index 1adc5ed..2f62e90 100644 --- a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs +++ b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs @@ -27,17 +27,18 @@ public class KpiAreaInfoImportHelperTests } [Fact] - public void PrepareEntityForImport_MissingPostcodePair_ShouldThrow() + public void PrepareEntityForImport_PrefixOnlyLeft_ShouldSucceed() { var entity = new KpiAreaInfo { CountryCode = "US", CountryName = "美国", - LeftPostcode = "90000" + LeftPostcode = "90" }; - var act = () => KpiAreaInfoImportHelper.PrepareEntityForImport(entity, new Dictionary()); + KpiAreaInfoImportHelper.PrepareEntityForImport(entity, new Dictionary()); - act.Should().Throw().WithMessage("*截止邮编*"); + entity.LeftPostcode.Should().Be("90"); + entity.RightPostcode.Should().BeNull(); } } diff --git a/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs b/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs index a612862..58f5cf4 100644 --- a/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs +++ b/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs @@ -5,19 +5,21 @@ namespace Oms.Tests; public class KpiAreaSnapshotMapperTests { - [Theory] - [InlineData("06000", "(00000,50000]", true)] - [InlineData("00000", "(00000,50000]", false)] - [InlineData("50000", "(00000,50000]", true)] - [InlineData("50001", "(00000,50000]", false)] - public void IsZipInLeftOpenRightClosedRange_ShouldMatchExpected(string zip, string range, bool expected) + [Fact] + public void BuildPostcodeRange_ShouldEncodeClosedRange() { - KpiAreaSnapshotMapper.IsZipInLeftOpenRightClosedRange(zip, range).Should().Be(expected); + KpiAreaSnapshotMapper.BuildPostcodeRange("00000", "50000").Should().Be("[00000,50000]"); } [Fact] - public void BuildPostcodeRange_ShouldFormatLeftOpenRightClosed() + public void BuildPostcodeRange_EqualBounds_ShouldEncodeExact() { - KpiAreaSnapshotMapper.BuildPostcodeRange("00000", "50000").Should().Be("(00000,50000]"); + KpiAreaSnapshotMapper.BuildPostcodeRange("90001", "90001").Should().Be("exact:90001"); + } + + [Fact] + public void BuildPostcodeRange_LeftOnly_ShouldEncodePrefix() + { + KpiAreaSnapshotMapper.BuildPostcodeRange("90", null).Should().Be("prefix:90"); } } -- 2.45.2 From c91d8ca0e6d5202586d9a09f5cb60eae1f667da9 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 5 Oct 2026 16:30:18 +0800 Subject: [PATCH 20/41] =?UTF-8?q?test:=20=E8=A1=A5=E9=BD=90=20GB/CA=20?= =?UTF-8?q?=E5=AD=97=E6=AF=8D=E6=95=B0=E5=AD=97=E9=82=AE=E7=BC=96=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E3=80=81=E7=B2=BE=E7=A1=AE=E4=B8=8E=E9=97=AD=E5=8C=BA?= =?UTF-8?q?=E9=97=B4=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/Oms.Tests/GeoPartitionMatcherTests.cs | 107 ++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs index 567998a..98d8d3a 100644 --- a/src/Oms.Tests/GeoPartitionMatcherTests.cs +++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs @@ -118,6 +118,110 @@ public class GeoPartitionMatcherTests act.Should().Throw().WithMessage("*州*"); } + [Theory] + [InlineData("BT9 7AA", "BT97AA")] + [InlineData("bt97aa", "BT97AA")] + [InlineData("BT9-7AA", "BT97AA")] + [InlineData("B46 3AB", "B463AB")] + [InlineData(" b46 3ab ", "B463AB")] + public void NormalizeZip_AlphanumericWithSpaces_ShouldStripAndUpper(string raw, string expected) + { + GeoPartitionMatcher.NormalizeZip(raw).Should().Be(expected); + } + + [Fact] + public void Gb_Bt97aa_LongerOutwardPrefixWins() + { + var rules = CompileEntities( + Zip("GB", "BT"), + Zip("GB", "BT9"), + Zip("GB", "BT97"), + Zip("GB", "B")); + + var hit = GeoPartitionMatcher.MatchEntity(rules, "GB", "NIR", "Belfast", "BT9 7AA"); + hit!.LeftPostcode.Should().Be("BT97"); + GeoPartitionMatcher.MatchEntity(rules, "gb", null, null, "BT97AA")!.LeftPostcode.Should().Be("BT97"); + } + + [Fact] + public void Gb_Bt97aa_ExactIgnoresSpaces() + { + var rules = CompileEntities(Exact("GB", "BT9 7AA")); + + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT97AA").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT9 7AA").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT9 7AB").Should().BeFalse(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT98AA").Should().BeFalse(); + } + + [Fact] + public void Gb_Bt97aa_ClosedAlphanumericRange() + { + var rules = CompileEntities(Range("GB", "BT90AA", "BT99ZZ")); + + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT97AA").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT9 7AA").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT90AA").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT99ZZ").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "BT8 1AA").Should().BeFalse(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "E1 6AN").Should().BeFalse(); + } + + [Fact] + public void Ca_B463ab_PrefixAndSpaceVariants() + { + var rules = CompileEntities( + Zip("CA", "B"), + Zip("CA", "B4"), + Zip("CA", "B46"), + Zip("CA", "B463")); + + var hit = GeoPartitionMatcher.MatchEntity(rules, "CA", "ON", "Toronto", "B46 3AB"); + hit!.LeftPostcode.Should().Be("B463"); + GeoPartitionMatcher.IsHit(rules, "CA", null, null, "B463AB").Should().BeTrue(); + GeoPartitionMatcher.MatchEntity(rules, "CA", null, null, "B47 3AB")!.LeftPostcode.Should().Be("B4"); + GeoPartitionMatcher.IsHit(rules, "CA", null, null, "M5V 2T6").Should().BeFalse(); + } + + [Fact] + public void Ca_B463ab_ExactAndRange() + { + var exact = CompileEntities(Exact("CA", "B46 3AB")); + var range = CompileEntities(Range("CA", "B460AA", "B469ZZ")); + + GeoPartitionMatcher.IsHit(exact, "CA", null, null, "B46 3AB").Should().BeTrue(); + GeoPartitionMatcher.IsHit(exact, "CA", null, null, "B463AB").Should().BeTrue(); + GeoPartitionMatcher.IsHit(exact, "CA", null, null, "B46 3AC").Should().BeFalse(); + + GeoPartitionMatcher.IsHit(range, "CA", null, null, "B46 3AB").Should().BeTrue(); + GeoPartitionMatcher.IsHit(range, "CA", null, null, "B46 0AA").Should().BeTrue(); + GeoPartitionMatcher.IsHit(range, "CA", null, null, "B47 3AB").Should().BeFalse(); + } + + [Fact] + public void GbAndCa_SameLookingPostcode_CountryMustMatch() + { + var gb = CompileEntities(Zip("GB", "B46"), Exact("GB", "BT97AA")); + var ca = CompileEntities(Zip("CA", "B46")); + + GeoPartitionMatcher.IsHit(gb, "GB", null, null, "B46 3AB").Should().BeTrue(); + GeoPartitionMatcher.IsHit(gb, "CA", null, null, "B46 3AB").Should().BeFalse(); + GeoPartitionMatcher.IsHit(ca, "CA", null, null, "B46 3AB").Should().BeTrue(); + GeoPartitionMatcher.IsHit(ca, "GB", null, null, "BT97AA").Should().BeFalse(); + GeoPartitionMatcher.IsHit(gb, "GB", null, null, "BT97AA").Should().BeTrue(); + } + + [Fact] + public void Gb_Bt97aa_ZipPresent_ShouldSkipState() + { + var rules = CompileEntities( + State("GB", "NIR"), + Zip("GB", "BT1")); + + GeoPartitionMatcher.MatchEntity(rules, "GB", "NIR", "Belfast", "BT97AA").Should().BeNull(); + GeoPartitionMatcher.MatchEntity(rules, "GB", "NIR", "Belfast", null)!.Province.Should().Be("NIR"); + } + private static List CompileEntities(params KpiAreaInfo[] items) { foreach (var item in items) @@ -131,6 +235,9 @@ public class GeoPartitionMatcherTests private static KpiAreaInfo Zip(string country, string prefix) => new() { CountryCode = country, LeftPostcode = prefix }; + private static KpiAreaInfo Exact(string country, string postcode) => + new() { CountryCode = country, LeftPostcode = postcode, RightPostcode = postcode }; + private static KpiAreaInfo Range(string country, string left, string right) => new() { CountryCode = country, LeftPostcode = left, RightPostcode = right }; -- 2.45.2 From b748aa40007da8a0ab8b2727ccc4af8160865308 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 5 Oct 2026 17:35:18 +0800 Subject: [PATCH 21/41] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E5=88=86?= =?UTF-8?q?=E5=8C=BA=20range=E3=80=81=E8=BF=9B=E4=BD=8D=E7=9B=B8=E6=8E=A5?= =?UTF-8?q?=E4=B8=8E=E4=BB=85=E5=9B=BD=E5=AE=B6=E4=BC=B0=E7=AE=97=E5=8F=A3?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进位区间改为左开右闭且禁止空洞,邮编 range 按数字/字母数字比较,查价仅国家时不再串用别国 zone。 Co-authored-by: Cursor --- .../Channels/WeightRoundingService.cs | 12 +++ .../Orders/ParcelBillingCalculator.cs | 40 ++++++-- .../Services/Orders/PriceCheckInputProfile.cs | 5 + .../Shared/GeoPartitionMatcher.cs | 80 ++++++++++++++- .../Shared/WeightRoundingRangeGuard.cs | 75 ++++++++++++++ src/Oms.Tests/GeoPartitionMatcherTests.cs | 32 ++++++ src/Oms.Tests/ParcelBillingCalculatorTests.cs | 23 +++++ .../PriceCheckSnapshotVerificationTests.cs | 23 ++++- .../WeightRoundingRangeGuardTests.cs | 97 +++++++++++++++++++ 9 files changed, 370 insertions(+), 17 deletions(-) create mode 100644 src/Oms.Application/Shared/WeightRoundingRangeGuard.cs create mode 100644 src/Oms.Tests/WeightRoundingRangeGuardTests.cs diff --git a/src/Oms.Application/Services/Channels/WeightRoundingService.cs b/src/Oms.Application/Services/Channels/WeightRoundingService.cs index c919d5b..a24f4a7 100644 --- a/src/Oms.Application/Services/Channels/WeightRoundingService.cs +++ b/src/Oms.Application/Services/Channels/WeightRoundingService.cs @@ -113,6 +113,7 @@ public class WeightRoundingService : ApplicationService public async Task AddInfoAsync(WeightRoundingInfoSaveInput input) { await GetEntityOrThrowAsync(input.RoundingId); + await ValidateInfoRangeAsync(input); await WeightRoundingRepository.InsertInfoAsync(new WeightRoundingInfo { RoundingId = input.RoundingId, @@ -134,6 +135,7 @@ public class WeightRoundingService : ApplicationService { var entity = await GetInfoOrThrowAsync(input.Id); await GetEntityOrThrowAsync(input.RoundingId); + await ValidateInfoRangeAsync(input, entity.Id); entity.RoundingId = input.RoundingId; entity.StartValue = input.StartValue; entity.EndValue = input.EndValue; @@ -202,6 +204,16 @@ public class WeightRoundingService : ApplicationService return entity; } + private async Task ValidateInfoRangeAsync(WeightRoundingInfoSaveInput input, int? excludeId = null) + { + var existed = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(input.RoundingId); + WeightRoundingRangeGuard.EnsureContiguous( + input.StartValue, + input.EndValue, + existed, + excludeId ?? input.Id); + } + private static WeightRoundingOutput MapOutput(WeightRounding entity) { return new WeightRoundingOutput diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 0858b86..3b781c8 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -66,8 +66,9 @@ internal static class ParcelBillingCalculator public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList ranges) { var hit = ranges - .OrderBy(x => x.Id) - .FirstOrDefault(x => rawWeight >= x.StartValue && rawWeight <= x.EndValue); + .OrderBy(x => x.StartValue) + .ThenBy(x => x.Id) + .FirstOrDefault(x => WeightInLeftOpenRightClosedInterval(rawWeight, x.StartValue, x.EndValue)); if (hit == null) { throw new InvalidOperationException("计重进位区间未匹配"); @@ -129,8 +130,8 @@ internal static class ParcelBillingCalculator } /// - /// 查价仅填国家时:若快照已配置该目的国分区(即使规则带邮编区间), - /// 则返回卖价明细中的分区键供估算报价;未配置该国家时返回空(不跨国家兜底)。 + /// 查价仅填国家时:只使用该国分区规则对应的 zone 键(及与这些键重合的卖价明细分区)。 + /// 未配置该国家时返回空;不把别国明细分区拿来估算。 /// public static IReadOnlyList CollectCountryEstimateZoneKeys( SalesProductPricePublishSnapshotDto? priceSnapshot, @@ -143,15 +144,34 @@ internal static class ParcelBillingCalculator return []; } - var countryConfigured = priceSnapshot.PriceAreaInfos.Any(rule => - string.IsNullOrWhiteSpace(rule.CountryCode) - || string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase)); - if (!countryConfigured) + var countryRules = priceSnapshot.PriceAreaInfos + .Where(rule => string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (countryRules.Count == 0) { return []; } - return CollectDistinctDetailZoneKeys(priceSnapshot); + var allowed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var rule in countryRules) + { + foreach (var key in BuildPriceZoneKeys(rule, priceSnapshot.PriceAreaName, priceSnapshot.PriceAreaId)) + { + if (!string.IsNullOrWhiteSpace(key)) + { + allowed.Add(key.Trim()); + } + } + } + + var detailZones = CollectDistinctDetailZoneKeys(priceSnapshot); + var mapped = detailZones.Where(zone => allowed.Contains(zone)).ToList(); + if (mapped.Count > 0) + { + return mapped; + } + + return allowed.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); } public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area) @@ -2170,7 +2190,7 @@ internal static class ParcelBillingCalculator } catch { - return false; + throw new InvalidOperationException($"计重规则条件无效:{condition}"); } } diff --git a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs index 895f8c3..c83a0c1 100644 --- a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs +++ b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs @@ -52,6 +52,11 @@ internal sealed class PriceCheckInputProfile missing.Add(PriceCheckMissingFieldCodes.PostalCode); } + if (!hasPostal && !hasCity) + { + missing.Add(PriceCheckMissingFieldCodes.City); + } + if (!hasProvince) { missing.Add(PriceCheckMissingFieldCodes.Province); diff --git a/src/Oms.Application/Shared/GeoPartitionMatcher.cs b/src/Oms.Application/Shared/GeoPartitionMatcher.cs index 1aaae50..4eab0fd 100644 --- a/src/Oms.Application/Shared/GeoPartitionMatcher.cs +++ b/src/Oms.Application/Shared/GeoPartitionMatcher.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.RegularExpressions; using Oms.Application.Dtos.Orders; using Oms.Domain.ChannelsAggregate.Entities; @@ -65,6 +66,79 @@ public static class GeoPartitionMatcher return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant(); } + /// + /// 规范化后的邮编比较:纯数字按等长补零;相同字母前缀+数字后缀按数值;其余按 Ordinal。 + /// + public static int CompareNormalizedZip(string? left, string? right) + { + var a = left ?? string.Empty; + var b = right ?? string.Empty; + if (a.Length == 0 && b.Length == 0) + { + return 0; + } + + if (IsDigits(a) && IsDigits(b)) + { + var width = Math.Max(a.Length, b.Length); + return string.CompareOrdinal(a.PadLeft(width, '0'), b.PadLeft(width, '0')); + } + + if (TrySplitLetterPrefixNumber(a, out var prefixA, out var numberA, out var restA) + && TrySplitLetterPrefixNumber(b, out var prefixB, out var numberB, out var restB) + && string.Equals(prefixA, prefixB, StringComparison.Ordinal)) + { + var numberCmp = numberA.CompareTo(numberB); + return numberCmp != 0 ? numberCmp : string.CompareOrdinal(restA, restB); + } + + return string.CompareOrdinal(a, b); + } + + private static bool IsDigits(string value) => + value.Length > 0 && value.All(char.IsAsciiDigit); + + private static bool TrySplitLetterPrefixNumber( + string zip, + out string prefix, + out int number, + out string rest) + { + prefix = string.Empty; + number = 0; + rest = string.Empty; + if (zip.Length == 0 || !char.IsAsciiLetter(zip[0])) + { + return false; + } + + var i = 0; + while (i < zip.Length && char.IsAsciiLetter(zip[i])) + { + i++; + } + + if (i >= zip.Length || !char.IsAsciiDigit(zip[i])) + { + return false; + } + + prefix = zip[..i]; + var digitStart = i; + while (i < zip.Length && char.IsAsciiDigit(zip[i])) + { + i++; + } + + if (!int.TryParse(zip[digitStart..i], NumberStyles.None, CultureInfo.InvariantCulture, out number)) + { + return false; + } + + rest = zip[i..]; + return true; + } + public static string NormalizeRegion(string? value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); @@ -116,8 +190,8 @@ public static class GeoPartitionMatcher } else if (rule.MatchMode == ModeRange) { - hit = zipStd.CompareTo(rule.ZipStartStd) >= 0 - && zipStd.CompareTo(rule.ZipEndStd) <= 0; + hit = CompareNormalizedZip(zipStd, rule.ZipStartStd) >= 0 + && CompareNormalizedZip(zipStd, rule.ZipEndStd) <= 0; } if (hit) @@ -565,7 +639,7 @@ public static class GeoPartitionMatcher throw new InvalidOperationException("邮编区间必须填写截止邮编"); } - if (zipLeft.CompareTo(zipRight) > 0) + if (CompareNormalizedZip(zipLeft, zipRight) > 0) { throw new InvalidOperationException("起始邮编不能大于截止邮编"); } diff --git a/src/Oms.Application/Shared/WeightRoundingRangeGuard.cs b/src/Oms.Application/Shared/WeightRoundingRangeGuard.cs new file mode 100644 index 0000000..f2c2c84 --- /dev/null +++ b/src/Oms.Application/Shared/WeightRoundingRangeGuard.cs @@ -0,0 +1,75 @@ +using Fake.Application; +using Oms.Domain.ChannelsAggregate.Entities; + +namespace Oms.Application.Shared; + +/// +/// 计重进位区间校验:同一进位制下左开右闭 (start, end],端点相接不算重叠,排序后不允许空洞。 +/// +public static class WeightRoundingRangeGuard +{ + public static void EnsureValidRange(decimal startValue, decimal endValue) + { + if (startValue >= endValue) + { + throw new BusinessException("起始值须小于截止值"); + } + } + + /// + /// (a, b] 与 (c, d] 是否重叠。 + /// + public static bool Overlaps(decimal startA, decimal endA, decimal startB, decimal endB) => + startA < endB && startB < endA; + + public static void EnsureNoOverlap( + decimal startValue, + decimal endValue, + IEnumerable existing, + int? excludeId = null) + { + EnsureValidRange(startValue, endValue); + foreach (var item in existing) + { + if (excludeId is > 0 && item.Id == excludeId.Value) + { + continue; + } + + if (Overlaps(startValue, endValue, item.StartValue, item.EndValue)) + { + throw new BusinessException( + $"起始区间 ({startValue}, {endValue}] 与已有区间 ({item.StartValue}, {item.EndValue}] 重叠"); + } + } + } + + public static void EnsureContiguous( + decimal startValue, + decimal endValue, + IEnumerable existing, + int? excludeId = null) + { + EnsureNoOverlap(startValue, endValue, existing, excludeId); + var merged = existing + .Where(item => excludeId is not > 0 || item.Id != excludeId.Value) + .Select(item => (Start: item.StartValue, End: item.EndValue)) + .Append((Start: startValue, End: endValue)) + .OrderBy(x => x.Start) + .ThenBy(x => x.End) + .ToList(); + + for (var i = 0; i < merged.Count - 1; i++) + { + var currentEnd = merged[i].End; + var nextStart = merged[i + 1].Start; + if (currentEnd == nextStart) + { + continue; + } + + throw new BusinessException( + $"起始区间必须首尾相接(左开右闭):({merged[i].Start}, {currentEnd}] 与 ({nextStart}, {merged[i + 1].End}] 之间存在缺口"); + } + } +} diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs index 98d8d3a..e2a1afc 100644 --- a/src/Oms.Tests/GeoPartitionMatcherTests.cs +++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs @@ -59,6 +59,38 @@ public class GeoPartitionMatcherTests GeoPartitionMatcher.IsHit(rules, "US", null, null, "90200").Should().BeFalse(); } + [Fact] + public void ZipRange_NumericDifferentLength_ShouldCompareAsNumbers() + { + var rules = CompileEntities(Range("US", "9", "10")); + + GeoPartitionMatcher.IsHit(rules, "US", null, null, "9").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "10").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "8").Should().BeFalse(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "11").Should().BeFalse(); + } + + [Fact] + public void ZipRange_LeadingZeros_ShouldKeepZipWidth() + { + var rules = CompileEntities(Range("US", "02100", "02200")); + + GeoPartitionMatcher.IsHit(rules, "US", null, null, "02115").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "02100").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "02200").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "02099").Should().BeFalse(); + } + + [Fact] + public void ZipRange_AlphaNumericPrefix_ShouldCompareNumericTail() + { + var rules = CompileEntities(Range("GB", "YO0", "YO9")); + + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "YO0").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "YO9").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "YO10").Should().BeFalse(); + } + [Fact] public void CountryFallback_ShouldHitWhenZipMisses() { diff --git a/src/Oms.Tests/ParcelBillingCalculatorTests.cs b/src/Oms.Tests/ParcelBillingCalculatorTests.cs index 91b621d..65ef824 100644 --- a/src/Oms.Tests/ParcelBillingCalculatorTests.cs +++ b/src/Oms.Tests/ParcelBillingCalculatorTests.cs @@ -48,6 +48,29 @@ public class ParcelBillingCalculatorTests rounded.Should().Be(1.5M); } + [Fact] + public void ApplyRounding_LeftOpenRightClosed_BoundaryGoesToLeftRange() + { + var ranges = new[] + { + ParcelBillingTestData.RoundingRange(0, 10, "0.5"), + ParcelBillingTestData.RoundingRange(10, 30, "1"), + }; + + ParcelBillingCalculator.ApplyRounding(10M, ranges).Should().Be(10M); + ParcelBillingCalculator.ApplyRounding(10.01M, ranges).Should().Be(11M); + } + + [Fact] + public void ApplyRounding_LeftOpenRightClosed_StartIsExclusive() + { + var ranges = new[] { ParcelBillingTestData.RoundingRange(0, 30, "0.5") }; + + var act = () => ParcelBillingCalculator.ApplyRounding(0M, ranges); + + act.Should().Throw().WithMessage("*计重进位区间未匹配*"); + } + #endregion #region 应收(卖价) diff --git a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs index 0dd3f7d..a22ed8f 100644 --- a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs +++ b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs @@ -308,23 +308,38 @@ public class PriceCheckSnapshotVerificationTests public void CountryEstimate_WhenCountryConfiguredWithPostcodeRules_ShouldReturnDetailZones() { var snapshot = BuildUserSnapshot(); + snapshot.Prices.Add(PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput + { + Id = 9, + Status = 2, + CostItem = "freight", + Details = [Detail(90, 9, "total", "99", "0,2", 1)] + })); snapshot.PriceAreaInfos = [ new KpiAreaSnapshotRuleDto { - Id = 11, + Id = 3, CountryCode = "US", PostcodeRange = "(90000,99999]" + }, + new KpiAreaSnapshotRuleDto + { + Id = 99, + CountryCode = "DE", + PostcodeRange = "(00000,99999]" } ]; - // 精确匹配(无邮编)应失败 ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(snapshot, "US", null, null, null) .Should().BeNull(); - // 国家估算应返回卖价明细分区键 ParcelBillingCalculator.CollectCountryEstimateZoneKeys(snapshot, "US") - .Should().Contain(["3"]); + .Should().Contain("3") + .And.NotContain("99"); + ParcelBillingCalculator.CollectCountryEstimateZoneKeys(snapshot, "DE") + .Should().Contain("99") + .And.NotContain("3"); } [Fact] diff --git a/src/Oms.Tests/WeightRoundingRangeGuardTests.cs b/src/Oms.Tests/WeightRoundingRangeGuardTests.cs new file mode 100644 index 0000000..24f0236 --- /dev/null +++ b/src/Oms.Tests/WeightRoundingRangeGuardTests.cs @@ -0,0 +1,97 @@ +using Fake.Application; +using FluentAssertions; +using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Entities; + +namespace Oms.Tests; + +public class WeightRoundingRangeGuardTests +{ + [Fact] + public void AdjacentLeftOpenRightClosed_ShouldNotOverlap() + { + var existing = new[] { Range(1, 0, 30) }; + + var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(30, 60, existing); + + act.Should().NotThrow(); + } + + [Fact] + public void ContainedRange_ShouldOverlap() + { + var existing = new[] { Range(1, 0, 30) }; + + var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(5, 10, existing); + + act.Should().Throw().WithMessage("*重叠*"); + } + + [Fact] + public void CrossingRange_ShouldOverlap() + { + var existing = new[] { Range(1, 0, 30) }; + + var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(20, 40, existing); + + act.Should().Throw(); + } + + [Fact] + public void UpdateSameRow_ShouldIgnoreSelf() + { + var existing = new[] { Range(8, 0, 30), Range(9, 30, 60) }; + + var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(0, 30, existing, excludeId: 8); + + act.Should().NotThrow(); + } + + [Fact] + public void StartNotLessThanEnd_ShouldFail() + { + var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(30, 30, []); + + act.Should().Throw().WithMessage("*起始值须小于截止值*"); + } + + [Fact] + public void HoleBetweenRanges_ShouldFail() + { + var existing = new[] { Range(1, 0, 10) }; + + var act = () => WeightRoundingRangeGuard.EnsureContiguous(20, 30, existing); + + act.Should().Throw().WithMessage("*缺口*"); + } + + [Fact] + public void AdjacentRanges_ShouldBeContiguous() + { + var existing = new[] { Range(1, 0, 10) }; + + var act = () => WeightRoundingRangeGuard.EnsureContiguous(10, 25, existing); + + act.Should().NotThrow(); + } + + [Fact] + public void UpdateSameRow_Contiguous_ShouldIgnoreSelf() + { + var existing = new[] { Range(8, 0, 30), Range(9, 30, 60) }; + + var act = () => WeightRoundingRangeGuard.EnsureContiguous(0, 30, existing, excludeId: 8); + + act.Should().NotThrow(); + } + + private static WeightRoundingInfo Range(int id, decimal start, decimal end) => + new() + { + Id = id, + RoundingId = 1, + StartValue = start, + EndValue = end, + Public = "0.5" + }; +} -- 2.45.2 From f063c99d1b52286ff56b0336a979e32dd6d9b783 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 5 Oct 2026 18:48:21 +0800 Subject: [PATCH 22/41] =?UTF-8?q?feat:=20OpenAPI=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9/=E6=96=87=E4=BB=B6=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=EF=BC=8C=E5=88=9B=E5=8D=95=E6=8C=89=20clientNo=20=E5=B9=82?= =?UTF-8?q?=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户 HMAC 通过后再查本客户订单;禁止下游面单,附件增加 POD。 Co-authored-by: Cursor --- db.sql | 2 +- .../Dtos/OpenApi/OpenApiQueryDtos.cs | 92 +++++++++ .../ISmallBagTrackOrderClient.cs | 9 + .../SmallBagTrack/SmallBagTrackOrderClient.cs | 174 ++++++++++++++++++ .../CustomerOpenApiCredentialService.cs | 5 +- .../Services/OpenApi/OpenApiService.cs | 139 +++++++++++++- .../Services/Orders/OrderFileManager.cs | 24 ++- .../Services/Orders/OrderService.cs | 40 ++++ .../Shared/OpenApiCustomerOrderResolver.cs | 54 ++++++ .../Consts/OrderFileTypeCodes.cs | 17 +- .../OrdersAggregate/Entities/OrderFile.cs | 2 +- 11 files changed, 550 insertions(+), 8 deletions(-) create mode 100644 src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs create mode 100644 src/Oms.Application/Shared/OpenApiCustomerOrderResolver.cs diff --git a/db.sql b/db.sql index 009c706..b2c744a 100644 --- a/db.sql +++ b/db.sql @@ -214,7 +214,7 @@ CREATE TABLE `t_order` ( CREATE TABLE `t_order_file` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增', `OrderId` INT NOT NULL COMMENT '订单Id', - `FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片', + `FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片/pod签收证明', `FileName` VARCHAR(255) NULL COMMENT '原始文件名', `FileUrl` VARCHAR(512) NOT NULL COMMENT '文件地址(相对路径或外链)', `ContentType` VARCHAR(128) NULL COMMENT '文件MIME类型', diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs new file mode 100644 index 0000000..a1299f9 --- /dev/null +++ b/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs @@ -0,0 +1,92 @@ +namespace Oms.Application.Dtos.OpenApi; + +/// +/// 按客户单号 / 服务单号 / 跟踪号定位订单(至少填一项)。 +/// +public class OpenApiOrderLookupInput +{ + public string? ClientNo { get; set; } + + public string? ServiceNo { get; set; } + + public string? TrackNo { get; set; } +} + +public class OpenApiTrackQueryInput : OpenApiOrderLookupInput +{ +} + +public class OpenApiFilesGetInput : OpenApiOrderLookupInput +{ + /// + /// 附件类型:tradeProof / systemLabel / lastMileLabel / infraredImage / pod。不含 downstreamLabel。 + /// + public string? FileType { get; set; } +} + +public class OpenApiTrackQueryOutput +{ + public int OrderId { get; set; } + + public string? ClientNo { get; set; } + + public string? ServiceNo { get; set; } + + public string? TrackNo { get; set; } + + public List Events { get; set; } = []; +} + +public class OpenApiTrackEventDto +{ + public string? TrackCode { get; set; } + + public string? Status { get; set; } + + public string? Content { get; set; } + + public string? EnContent { get; set; } + + public string? Location { get; set; } + + public DateTime? Time { get; set; } + + public List Files { get; set; } = []; +} + +public class OpenApiTrackFileDto +{ + public string? FileName { get; set; } + + public string? Url { get; set; } +} + +public class OpenApiFilesGetOutput +{ + public int OrderId { get; set; } + + public string? ClientNo { get; set; } + + public string? ServiceNo { get; set; } + + public string FileType { get; set; } = string.Empty; + + public List Files { get; set; } = []; +} + +public class OpenApiFileItemDto +{ + public int Id { get; set; } + + public string? FileName { get; set; } + + public string FileUrl { get; set; } = string.Empty; + + public string? ContentType { get; set; } + + public long? FileSize { get; set; } + + public string? Source { get; set; } + + public DateTime CreateTime { get; set; } +} diff --git a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs index c3e8190..15b8a0b 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs @@ -1,3 +1,5 @@ +using Oms.Application.Dtos.OpenApi; + namespace Oms.Application.Integrations.SmallBagTrack; public interface ISmallBagTrackOrderClient @@ -45,4 +47,11 @@ public interface ISmallBagTrackOrderClient int linkOrderType, string? authorization = null, CancellationToken cancellationToken = default); + + /// + /// 查询小包轨迹(POST ProviderService/Track/Query)。 + /// + Task> QuerySmallBagTracksAsync( + string orderNo, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs index ecb80a1..1d1189f 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs @@ -1,8 +1,10 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; +using Fake.Application; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using Oms.Application.Dtos.OpenApi; namespace Oms.Application.Integrations.SmallBagTrack; @@ -424,6 +426,178 @@ internal sealed class SmallBagTrackOrderClient( } } + public async Task> QuerySmallBagTracksAsync( + string orderNo, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(orderNo)) + { + throw new BusinessException("服务单号为空,无法查询轨迹"); + } + + var settings = await configProvider.GetAsync(cancellationToken); + if (!settings.Enabled) + { + throw new BusinessException("轨迹服务未启用"); + } + + if (string.IsNullOrWhiteSpace(settings.BaseUrl)) + { + throw new BusinessException("轨迹服务未配置"); + } + + if (string.IsNullOrWhiteSpace(settings.AppKey) || string.IsNullOrWhiteSpace(settings.AppSecret)) + { + throw new BusinessException("轨迹服务未配置"); + } + + var appKey = settings.AppKey.Trim(); + var url = $"{settings.BaseUrl.Trim().TrimEnd('/')}/ProviderService/Track/Query"; + var body = new + { + appKey, + orderType = SmallBagTrackLifecycleEvents.OrderType, + orderNos = new[] { orderNo.Trim() } + }; + + var response = await SendSignedPostAsync( + settings, + appKey, + url, + body, + authorization: null, + cancellationToken); + + if (!response.Response.IsSuccessStatusCode) + { + throw new BusinessException("轨迹查询失败"); + } + + return ParseTrackEvents(response.ResponseBody); + } + + private static List ParseTrackEvents(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var arr = root; + if (root.ValueKind == JsonValueKind.Object + && (root.TryGetProperty("data", out var data) || root.TryGetProperty("Data", out data))) + { + arr = data; + } + + if (arr.ValueKind != JsonValueKind.Array) + { + return []; + } + + var events = new List(); + foreach (var orderEl in arr.EnumerateArray()) + { + if (orderEl.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!orderEl.TryGetProperty("trackDetails", out var details) + && !orderEl.TryGetProperty("TrackDetails", out details)) + { + continue; + } + + if (details.ValueKind != JsonValueKind.Array) + { + continue; + } + + foreach (var el in details.EnumerateArray()) + { + var content = ReadJsonString(el, "content", "Content"); + if (string.IsNullOrWhiteSpace(content) || content == "--") + { + continue; + } + + events.Add(new OpenApiTrackEventDto + { + TrackCode = ReadJsonString(el, "code", "Code"), + Status = ReadJsonString(el, "status", "Status"), + Content = content, + EnContent = ReadJsonString(el, "enContent", "EnContent"), + Location = ReadJsonString(el, "location", "Location"), + Time = ReadJsonTime(el), + Files = ReadJsonFiles(el) + }); + } + } + + return events; + } + + private static List ReadJsonFiles(JsonElement el) + { + if (!el.TryGetProperty("files", out var files) && !el.TryGetProperty("Files", out files)) + { + return []; + } + + if (files.ValueKind != JsonValueKind.Array) + { + return []; + } + + var list = new List(); + foreach (var f in files.EnumerateArray()) + { + list.Add(new OpenApiTrackFileDto + { + FileName = ReadJsonString(f, "fileName", "FileName"), + Url = ReadJsonString(f, "downloadUrl", "DownloadUrl", "url", "Url") + }); + } + + return list; + } + + private static DateTime? ReadJsonTime(JsonElement el) + { + if (!el.TryGetProperty("time", out var time) && !el.TryGetProperty("Time", out time)) + { + return null; + } + + if (time.ValueKind == JsonValueKind.String && time.TryGetDateTime(out var dt)) + { + return dt; + } + + if (time.ValueKind == JsonValueKind.Number && time.TryGetInt64(out var unix)) + { + return DateTimeOffset.FromUnixTimeSeconds(unix > 10_000_000_000 ? unix / 1000 : unix).UtcDateTime; + } + + return null; + } + + private static string? ReadJsonString(JsonElement el, params string[] names) + { + foreach (var name in names) + { + if (el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String) + { + return p.GetString(); + } + } + + return null; + } + private async Task SendSignedPostAsync( SmallBagTrackOptions settings, string appKey, diff --git a/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs index 1381ab6..787e720 100644 --- a/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs +++ b/src/Oms.Application/Services/Customers/CustomerOpenApiCredentialService.cs @@ -181,13 +181,14 @@ public class CustomerOpenApiCredentialService : ApplicationService { if (!OmsSessionHelper.IsCustomerLogin(CurrentUser)) { - throw new BusinessException("仅客户门户可调用此接口"); + throw new BusinessException( + "当前是管理后台登录,不能调用客户门户接口。请到「客户」列表打开对应客户的开放接口凭证;客户请从客户门户登录后操作"); } var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser); if (!mapId.HasValue || mapId.Value <= 0) { - throw new BusinessException("客户未登录"); + throw new BusinessException("当前登录不是客户账号,无法读取开放接口凭证"); } var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == mapId.Value); diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs index 0076fbb..6d661e3 100644 --- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs +++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs @@ -6,8 +6,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.OpenApi; using Oms.Application.Dtos.Orders; +using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Services.Orders; using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; namespace Oms.Application.Services.OpenApi; @@ -33,6 +35,15 @@ public class OpenApiService : ApplicationService private OrderService OrderService => LazyServiceProvider.GetRequiredService(); + private OrderFileManager OrderFileManager => + LazyServiceProvider.GetRequiredService(); + + private OpenApiCustomerOrderResolver OrderResolver => + LazyServiceProvider.GetRequiredService(); + + private ISmallBagTrackOrderClient TrackClient => + LazyServiceProvider.GetRequiredService(); + private IHttpContextAccessor HttpContextAccessor => LazyServiceProvider.GetRequiredService(); @@ -84,7 +95,8 @@ public class OpenApiService : ApplicationService throw new BusinessException("请求体不能为空"); } - var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id)); + var data = await OrderService.CreateParcelForCustomerOpenApiAsync( + input.ToOrderParcelCreateInput(customer.Id)); return OpenApiEnvelope.Ok(data); } catch (BusinessException ex) @@ -103,6 +115,131 @@ public class OpenApiService : ApplicationService } } + /// + /// 查询轨迹。Header 同创建包裹。按 clientNo / serviceNo / trackNo 定位本客户订单。 + /// + [AllowAnonymous] + [HttpPost("/openapi/track/query")] + public async Task> QueryTrackAsync( + [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey, + [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp, + [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign) + { + try + { + var rawBody = await ReadRawBodyAsync(); + var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody); + var input = DeserializeBody(rawBody); + var order = await OrderResolver.ResolveAsync(customer.Id, input); + if (string.IsNullOrWhiteSpace(order.ServiceNo)) + { + throw new BusinessException("订单无服务单号,无法查询轨迹"); + } + + var events = await TrackClient.QuerySmallBagTracksAsync(order.ServiceNo); + return OpenApiEnvelope.Ok(new OpenApiTrackQueryOutput + { + OrderId = order.Id, + ClientNo = order.ClientNo, + ServiceNo = order.ServiceNo, + TrackNo = order.TrackNo, + Events = events + }); + } + catch (BusinessException ex) + { + return OpenApiEnvelope.Fail(ex.Message); + } + catch (JsonException) + { + return OpenApiEnvelope.Fail("请求体格式错误"); + } + catch (Exception ex) + { + return OpenApiEnvelope.Fail( + string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message, + "500"); + } + } + + /// + /// 按附件类型获取文件。Header 同创建包裹。 + /// fileType:tradeProof / systemLabel / lastMileLabel / infraredImage / pod。不含 downstreamLabel。 + /// + [AllowAnonymous] + [HttpPost("/openapi/files/get")] + public async Task> GetFilesAsync( + [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey, + [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp, + [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign) + { + try + { + var rawBody = await ReadRawBodyAsync(); + var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody); + var input = DeserializeBody(rawBody); + if (string.IsNullOrWhiteSpace(input.FileType)) + { + throw new BusinessException("请传入 fileType"); + } + + if (!OrderFileTypeCodes.IsCustomerOpenApiAllowed(input.FileType)) + { + throw new BusinessException("不支持的附件类型"); + } + + var order = await OrderResolver.ResolveAsync(customer.Id, input); + var files = await OrderFileManager.GetByTypeAsync(order.Id, input.FileType); + return OpenApiEnvelope.Ok(new OpenApiFilesGetOutput + { + OrderId = order.Id, + ClientNo = order.ClientNo, + ServiceNo = order.ServiceNo, + FileType = OrderFileTypeCodes.Normalize(input.FileType), + Files = files.Select(x => new OpenApiFileItemDto + { + Id = x.Id, + FileName = x.FileName, + FileUrl = x.FileUrl, + ContentType = x.ContentType, + FileSize = x.FileSize, + Source = x.Source, + CreateTime = x.CreateTime + }).ToList() + }); + } + catch (BusinessException ex) + { + return OpenApiEnvelope.Fail(ex.Message); + } + catch (JsonException) + { + return OpenApiEnvelope.Fail("请求体格式错误"); + } + catch (Exception ex) + { + return OpenApiEnvelope.Fail( + string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message, + "500"); + } + } + + private T DeserializeBody(string rawBody) + { + if (string.IsNullOrWhiteSpace(rawBody)) + { + throw new BusinessException("请求体不能为空"); + } + + var input = JsonSerializer.Deserialize(rawBody, BodyJsonOptions); + if (input == null) + { + throw new BusinessException("请求体不能为空"); + } + + return input; + } + private async Task ReadRawBodyAsync() { var http = HttpContextAccessor.HttpContext diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs index 62e273e..3cf553b 100644 --- a/src/Oms.Application/Services/Orders/OrderFileManager.cs +++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs @@ -66,6 +66,25 @@ public sealed class OrderFileManager( return result; } + public async Task> GetByTypeAsync( + int orderId, + string fileType, + CancellationToken cancellationToken = default) + { + if (!OrderFileTypeCodes.IsValid(fileType)) + { + throw new BusinessException("不支持的附件类型"); + } + + var type = OrderFileTypeCodes.Normalize(fileType); + var list = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken); + return list + .OrderBy(x => x.Sort) + .ThenBy(x => x.Id) + .Select(MapItem) + .ToList(); + } + public async Task GetGroupedAsync( int orderId, bool customerView, @@ -302,7 +321,8 @@ public sealed class OrderFileManager( or OrderFileTypeCodes.DownstreamLabel or OrderFileTypeCodes.LastMileLabel; var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage - or OrderFileTypeCodes.TradeProof; + or OrderFileTypeCodes.TradeProof + or OrderFileTypeCodes.Pod; if (isLabelType) { @@ -325,7 +345,7 @@ public sealed class OrderFileManager( { if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase)) { - throw new BusinessException("交易证明/红外图片仅支持图片或 pdf"); + throw new BusinessException("交易证明/红外图片/POD 仅支持图片或 pdf"); } if (file.Length > MaxPdfBytes) diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index f052699..4d7ab7f 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -531,6 +531,46 @@ public partial class OrderService : ApplicationService return output; } + /// + /// 客户 OpenAPI 创单:同一客户相同 clientNo 视为幂等,返回已存在订单,不新建。 + /// + public async Task CreateParcelForCustomerOpenApiAsync(OrderParcelCreateInput input) + { + NormalizeParcelCreateInput(input); + if (!string.IsNullOrWhiteSpace(input.ClientNo) && input.CustomerId is > 0) + { + var existing = await OrderRepository.GetByCustomerAndClientNoAsync( + input.CustomerId.Value, + input.ClientNo); + if (existing != null) + { + return await BuildParcelCreateOutputAsync(existing); + } + } + + try + { + return await CreateParcelAsync(input); + } + catch (BusinessException ex) when (ex.Message == "客户单号已存在") + { + if (string.IsNullOrWhiteSpace(input.ClientNo) || input.CustomerId is not > 0) + { + throw; + } + + var raced = await OrderRepository.GetByCustomerAndClientNoAsync( + input.CustomerId.Value, + input.ClientNo); + if (raced == null) + { + throw; + } + + return await BuildParcelCreateOutputAsync(raced); + } + } + private async Task TryAttachPickupAfterParcelCreateAsync( OrderParcelCreateInput input, OrderParcelCreateOutput output) diff --git a/src/Oms.Application/Shared/OpenApiCustomerOrderResolver.cs b/src/Oms.Application/Shared/OpenApiCustomerOrderResolver.cs new file mode 100644 index 0000000..565cbce --- /dev/null +++ b/src/Oms.Application/Shared/OpenApiCustomerOrderResolver.cs @@ -0,0 +1,54 @@ +using Fake.Application; +using Fake.DependencyInjection; +using Oms.Application.Dtos.OpenApi; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Shared; + +/// +/// OpenAPI 按客户凭证解析自有订单。 +/// +public class OpenApiCustomerOrderResolver : IScopedDependency +{ + private readonly IOrderRepository _orderRepository; + + public OpenApiCustomerOrderResolver(IOrderRepository orderRepository) + { + _orderRepository = orderRepository; + } + + public async Task ResolveAsync(int customerId, OpenApiOrderLookupInput input) + { + var clientNo = input.ClientNo?.Trim(); + var serviceNo = input.ServiceNo?.Trim(); + var trackNo = input.TrackNo?.Trim(); + if (string.IsNullOrWhiteSpace(clientNo) + && string.IsNullOrWhiteSpace(serviceNo) + && string.IsNullOrWhiteSpace(trackNo)) + { + throw new BusinessException("请传入 clientNo、serviceNo 或 trackNo"); + } + + Order? order = null; + if (!string.IsNullOrWhiteSpace(clientNo)) + { + order = await _orderRepository.GetByCustomerAndClientNoAsync(customerId, clientNo); + } + else if (!string.IsNullOrWhiteSpace(serviceNo)) + { + order = await _orderRepository.GetByServiceNoAsync(serviceNo); + } + else if (!string.IsNullOrWhiteSpace(trackNo)) + { + order = await _orderRepository.GetByTrackNoAsync(trackNo); + } + + if (order == null || order.CustomerId != customerId) + { + throw new BusinessException("订单不存在"); + } + + return order; + } +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs index f82a107..986fd4c 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs @@ -10,6 +10,7 @@ public static class OrderFileTypeCodes public const string DownstreamLabel = "downstreamLabel"; public const string LastMileLabel = "lastMileLabel"; public const string InfraredImage = "infraredImage"; + public const string Pod = "pod"; public const int MaxFilesPerType = 20; @@ -19,7 +20,8 @@ public static class OrderFileTypeCodes SystemLabel, DownstreamLabel, LastMileLabel, - InfraredImage + InfraredImage, + Pod }; /// @@ -31,6 +33,19 @@ public static class OrderFileTypeCodes public static bool IsValid(string? fileType) => !string.IsNullOrWhiteSpace(fileType) && All.Contains(fileType.Trim()); + /// + /// 客户 OpenAPI 可查询的附件类型。不含下游面单。 + /// + public static bool IsCustomerOpenApiAllowed(string? fileType) + { + if (!IsValid(fileType)) + { + return false; + } + + return !string.Equals(fileType.Trim(), DownstreamLabel, StringComparison.OrdinalIgnoreCase); + } + public static string Normalize(string fileType) => fileType.Trim(); } diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs index 3e6ecdd..a6105d8 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs @@ -9,7 +9,7 @@ public class OrderFile : OmsAuditedAggregateRoot public int OrderId { get; set; } /// - /// 附件类型:tradeProof / systemLabel / downstreamLabel / lastMileLabel / infraredImage。 + /// 附件类型:tradeProof / systemLabel / downstreamLabel / lastMileLabel / infraredImage / pod。 /// [SugarColumn(Length = 32)] public string FileType { get; set; } = string.Empty; -- 2.45.2 From 5bf81b5138ede7fef5e5a376236fa06a1eb54daf Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 6 Oct 2026 10:39:28 +0800 Subject: [PATCH 23/41] =?UTF-8?q?fix:=20OpenAPI=20=E8=BD=A8=E8=BF=B9/?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=A1=A5=20Swagger=20=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E4=BD=93=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FromBody 仅用于文档展示,验签仍读原始 Body。 Co-authored-by: Cursor --- .../Dtos/OpenApi/OpenApiQueryDtos.cs | 5 +++++ .../Services/OpenApi/OpenApiService.cs | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs index a1299f9..90b6e26 100644 --- a/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs +++ b/src/Oms.Application/Dtos/OpenApi/OpenApiQueryDtos.cs @@ -5,17 +5,22 @@ namespace Oms.Application.Dtos.OpenApi; /// public class OpenApiOrderLookupInput { + /// 客户单号。与 serviceNo / trackNo 至少填一项;同时传时优先 clientNo。 public string? ClientNo { get; set; } + /// 服务单号(OMS 运单号)。 public string? ServiceNo { get; set; } + /// 跟踪号。 public string? TrackNo { get; set; } } +/// 查询轨迹入参。 public class OpenApiTrackQueryInput : OpenApiOrderLookupInput { } +/// 按附件类型获取文件入参。 public class OpenApiFilesGetInput : OpenApiOrderLookupInput { /// diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs index 6d661e3..bba414f 100644 --- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs +++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs @@ -79,10 +79,13 @@ public class OpenApiService : ApplicationService /// [AllowAnonymous] [HttpPost("/openapi/parcel/create")] + [Consumes("application/json")] + [Produces("application/json")] public async Task> CreateParcelAsync( [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey, [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp, - [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign) + [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign, + [FromBody] OpenApiParcelCreateInput? requestBody) { try { @@ -120,10 +123,13 @@ public class OpenApiService : ApplicationService /// [AllowAnonymous] [HttpPost("/openapi/track/query")] + [Consumes("application/json")] + [Produces("application/json")] public async Task> QueryTrackAsync( [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey, [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp, - [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign) + [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign, + [FromBody] OpenApiTrackQueryInput? requestBody) { try { @@ -168,10 +174,13 @@ public class OpenApiService : ApplicationService /// [AllowAnonymous] [HttpPost("/openapi/files/get")] + [Consumes("application/json")] + [Produces("application/json")] public async Task> GetFilesAsync( [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey, [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp, - [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign) + [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign, + [FromBody] OpenApiFilesGetInput? requestBody) { try { -- 2.45.2 From 04dc9ca1ffc37540c8816e18a9ef18a86070da80 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 6 Oct 2026 10:58:23 +0800 Subject: [PATCH 24/41] =?UTF-8?q?feat:=20=E5=88=86=E5=8C=BA=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E5=AF=BC=E5=85=A5=E6=A8=A1=E6=9D=BF=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E5=9B=BD=E5=AE=B6=E5=90=8D=E7=A7=B0=EF=BC=8C=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=9E=9A=E4=B8=BE=E5=80=BC=20Sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导入模板只保留国家代码;Sheet2 列出匹配维度/方式的编码与名称,导入时按代码补国家名称。 Co-authored-by: Cursor --- .../Shared/KpiAreaInfoImportHelper.cs | 58 +++++++++++++++++-- src/Oms.Tests/KpiAreaInfoImportHelperTests.cs | 58 +++++++++++++++++++ 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs index 2ebfabf..d61f58f 100644 --- a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs +++ b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs @@ -11,6 +11,9 @@ namespace Oms.Application.Shared; /// public static class KpiAreaInfoImportHelper { + public const string TemplateSheetName = "导入模板"; + public const string EnumSheetName = "枚举值"; + private static readonly Regex ChinesePattern = new(@"[\u4E00-\u9FA5]", RegexOptions.Compiled); private static readonly Dictionary HeaderAliases = new(StringComparer.OrdinalIgnoreCase) @@ -48,12 +51,11 @@ public static class KpiAreaInfoImportHelper public static async Task BuildTemplateBytesAsync() { - var rows = new List> + var templateRows = new List> { new() { ["国家代码"] = "US", - ["国家名称"] = "美国", ["匹配维度"] = "zip", ["匹配方式"] = "range", ["州/省"] = "", @@ -64,7 +66,6 @@ public static class KpiAreaInfoImportHelper new() { ["国家代码"] = "US", - ["国家名称"] = "美国", ["匹配维度"] = "zip", ["匹配方式"] = "prefix", ["州/省"] = "", @@ -75,7 +76,6 @@ public static class KpiAreaInfoImportHelper new() { ["国家代码"] = "US", - ["国家名称"] = "美国", ["匹配维度"] = "state", ["匹配方式"] = "exact", ["州/省"] = "CA", @@ -85,11 +85,44 @@ public static class KpiAreaInfoImportHelper } }; + var enumRows = new List> + { + new() { ["枚举类型"] = "匹配维度", ["编码"] = "country", ["名称"] = "仅国家" }, + new() { ["枚举类型"] = "匹配维度", ["编码"] = "zip", ["名称"] = "邮编" }, + new() { ["枚举类型"] = "匹配维度", ["编码"] = "state", ["名称"] = "州/省" }, + new() { ["枚举类型"] = "匹配维度", ["编码"] = "city", ["名称"] = "城市" }, + new() { ["枚举类型"] = "匹配方式", ["编码"] = "prefix", ["名称"] = "前缀" }, + new() { ["枚举类型"] = "匹配方式", ["编码"] = "exact", ["名称"] = "精确" }, + new() { ["枚举类型"] = "匹配方式", ["编码"] = "range", ["名称"] = "区间" }, + new() { ["枚举类型"] = "匹配方式", ["编码"] = "all", ["名称"] = "不限" } + }; + + var sheets = new Dictionary + { + [TemplateSheetName] = templateRows, + [EnumSheetName] = enumRows + }; + await using var stream = new MemoryStream(); - await MiniExcel.SaveAsAsync(stream, rows); + await MiniExcel.SaveAsAsync(stream, sheets); return stream.ToArray(); } + internal static string? ResolveImportSheetName(IEnumerable sheetNames) + { + var names = sheetNames.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (names.Count == 0) + { + return null; + } + + return names.FirstOrDefault(x => + string.Equals(x, TemplateSheetName, StringComparison.OrdinalIgnoreCase)) + ?? names.FirstOrDefault(x => + !string.Equals(x, EnumSheetName, StringComparison.OrdinalIgnoreCase)) + ?? names[0]; + } + public static string ResolveTemplateFileName(string? areaType) => areaType?.Trim().ToLowerInvariant() switch { @@ -101,7 +134,20 @@ public static class KpiAreaInfoImportHelper public static async Task> ParseImportRowsAsync(Stream fileStream) { - var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true); + if (fileStream.CanSeek) + { + fileStream.Position = 0; + } + + var sheetNames = MiniExcel.GetSheetNames(fileStream); + var sheetName = ResolveImportSheetName(sheetNames); + + if (fileStream.CanSeek) + { + fileStream.Position = 0; + } + + var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true, sheetName: sheetName); var result = new List<(int RowNumber, KpiAreaInfo Entity)>(); var rowNumber = 1; diff --git a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs index 2f62e90..03cdf8c 100644 --- a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs +++ b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using MiniExcelLibs; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.SystemAggregate.Entities; @@ -7,6 +8,63 @@ namespace Oms.Tests; public class KpiAreaInfoImportHelperTests { + [Fact] + public async Task BuildTemplateBytesAsync_ShouldUseNamedSheetsWithoutCountryName() + { + var bytes = await KpiAreaInfoImportHelper.BuildTemplateBytesAsync(); + await using var stream = new MemoryStream(bytes); + + var sheetNames = MiniExcel.GetSheetNames(stream); + sheetNames.Should().Equal( + KpiAreaInfoImportHelper.TemplateSheetName, + KpiAreaInfoImportHelper.EnumSheetName); + + stream.Position = 0; + var templateRows = (await MiniExcel.QueryAsync( + stream, + useHeaderRow: true, + sheetName: KpiAreaInfoImportHelper.TemplateSheetName)) + .Cast>() + .ToList(); + templateRows.Should().NotBeEmpty(); + templateRows[0].Keys.Should().NotContain(key => key.Contains("国家名称")); + templateRows[0].Should().ContainKey("国家代码"); + templateRows[0].Should().ContainKey("匹配维度"); + templateRows[0].Should().ContainKey("匹配方式"); + + stream.Position = 0; + var enumRows = (await MiniExcel.QueryAsync( + stream, + useHeaderRow: true, + sheetName: KpiAreaInfoImportHelper.EnumSheetName)) + .Cast>() + .ToList(); + enumRows.Should().HaveCount(8); + enumRows.Should().Contain(row => + Convert.ToString(row["枚举类型"]) == "匹配维度" + && Convert.ToString(row["编码"]) == "zip" + && Convert.ToString(row["名称"]) == "邮编"); + enumRows.Should().Contain(row => + Convert.ToString(row["枚举类型"]) == "匹配方式" + && Convert.ToString(row["编码"]) == "range" + && Convert.ToString(row["名称"]) == "区间"); + } + + [Fact] + public async Task ParseImportRowsAsync_ShouldReadTemplateSheetOnly() + { + var bytes = await KpiAreaInfoImportHelper.BuildTemplateBytesAsync(); + await using var stream = new MemoryStream(bytes); + + var rows = await KpiAreaInfoImportHelper.ParseImportRowsAsync(stream); + + rows.Should().HaveCount(3); + rows[0].Entity.CountryCode.Should().Be("US"); + rows[0].Entity.CountryName.Should().BeNull(); + rows[0].Entity.MatchDim.Should().Be("zip"); + rows[0].Entity.MatchMode.Should().Be("range"); + } + [Fact] public void PrepareEntityForImport_ValidRow_ShouldFillCountryName() { -- 2.45.2 From 14c42db7523391ba67f2355dfe39c663a149f54f Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 6 Oct 2026 12:21:06 +0800 Subject: [PATCH 25/41] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20/file-storag?= =?UTF-8?q?e=20=E6=8E=A5=E5=8F=A3=EF=BC=8C=E4=BE=9B=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=8C=89=E5=AF=B9=E8=B1=A1=20key=20=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补 normalize-key 与 stream,兼容带或不带 admin 前缀的 COS 路径。 Co-authored-by: Cursor --- .../Dtos/Storage/FileStorageDtos.cs | 49 +++ src/Oms.Application/OmsApplicationModule.cs | 6 + .../Services/Storage/FileStorageService.cs | 301 ++++++++++++++++++ .../Storage/FileStoragePath.cs | 58 ++++ src/Oms.Tests/FileStoragePathTests.cs | 19 ++ 5 files changed, 433 insertions(+) create mode 100644 src/Oms.Application/Dtos/Storage/FileStorageDtos.cs create mode 100644 src/Oms.Application/Services/Storage/FileStorageService.cs create mode 100644 src/Oms.Tests/FileStoragePathTests.cs diff --git a/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs b/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs new file mode 100644 index 0000000..2e38732 --- /dev/null +++ b/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs @@ -0,0 +1,49 @@ +namespace Oms.Application.Dtos.Storage; + +public class FileStorageNormalizeKeyInput +{ + public string? PathOrUrl { get; set; } +} + +public class FileStorageKeyOutput +{ + public string Key { get; set; } = string.Empty; +} + +public class FileStorageCapabilitiesOutput +{ + public bool SupportsClientDirectUpload { get; set; } + + public string? Provider { get; set; } +} + +public class FileStorageAccessUrlOutput +{ + public string Key { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; +} + +public class FileStoragePresignInput +{ + public string? Folder { get; set; } + + public string? FileName { get; set; } + + public string? ContentType { get; set; } +} + +public class FileStoragePresignOutput +{ + public string Key { get; set; } = string.Empty; + + public string UploadUrl { get; set; } = string.Empty; + + public string Method { get; set; } = "PUT"; + + public string? ContentType { get; set; } + + public Dictionary? Headers { get; set; } + + public string? ExpireAt { get; set; } +} diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs index acd9b8a..c24e699 100644 --- a/src/Oms.Application/OmsApplicationModule.cs +++ b/src/Oms.Application/OmsApplicationModule.cs @@ -12,6 +12,7 @@ using Oms.Application.Integrations.Itfx; using Oms.Application.Integrations.PlatformShop; using Oms.Application.Labeling; using Oms.Application.Labeling.Hiprint; +using Oms.Application.Services.Storage; using Oms.Application.Shared; using Oms.Application.Workers; using Oms.Domain; @@ -52,6 +53,11 @@ public class OmsApplicationModule : FakeModule context.Services.AddTransient(); context.Services.AddTransient(); + context.Services.AddHttpClient(FileStorageService.UrlFetchClientName, client => + { + client.Timeout = TimeSpan.FromSeconds(60); + }); + context.Services.AddOmsSmallBagTrack(configuration); context.Services.AddOmsItfxIntegration(configuration); context.Services.AddOmsPlatformShopIntegration(configuration); diff --git a/src/Oms.Application/Services/Storage/FileStorageService.cs b/src/Oms.Application/Services/Storage/FileStorageService.cs new file mode 100644 index 0000000..d043bc2 --- /dev/null +++ b/src/Oms.Application/Services/Storage/FileStorageService.cs @@ -0,0 +1,301 @@ +using System.Text.RegularExpressions; +using Fake.Application; +using Fake.ObjectStorage; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Storage; +using Oms.Application.Storage; + +namespace Oms.Application.Services.Storage; + +/// +/// 对象存储辅助 API(预览 stream / 规范化 key / 直传预签名)。 +/// +[ApiExplorerSettings(GroupName = "OMS")] +public class FileStorageService : ApplicationService +{ + public const string UrlFetchClientName = "oms-storage-url-fetch"; + + private static readonly HashSet AllowedFolders = new(StringComparer.OrdinalIgnoreCase) + { + "avatars", + "sku", + "parcel", + "recharge", + "order-files", + "order-templates", + "xhs", + "prints", + "exports", + "misc" + }; + + private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService(); + + private IHttpClientFactory HttpClientFactory => + LazyServiceProvider.GetRequiredService(); + + /// + /// 是否支持浏览器直传 COS。 + /// + [HttpGet("/file-storage/capabilities")] + public FileStorageCapabilitiesOutput GetCapabilities() + { + return new FileStorageCapabilitiesOutput + { + SupportsClientDirectUpload = !FileStorage.IsLocalProvider(), + Provider = FileStorage.Name + }; + } + + /// + /// 将 COS URL / 相对路径规范为 object key。 + /// + [HttpPost("/file-storage/normalize-key")] + public FileStorageKeyOutput NormalizeKey([FromBody] FileStorageNormalizeKeyInput input) + { + if (string.IsNullOrWhiteSpace(input?.PathOrUrl)) + { + throw new BusinessException("路径不能为空"); + } + + return new FileStorageKeyOutput { Key = FileStoragePath.NormalizeKey(input.PathOrUrl) }; + } + + /// + /// 按 key 换临时访问地址。 + /// + [HttpGet("/file-storage/access-url")] + public async Task GetAccessUrlAsync( + [FromQuery] string key, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new BusinessException("key 不能为空"); + } + + var trimmed = key.Trim(); + var url = await FileStorage.GetPublicUrlAsync(trimmed, cancellationToken); + if (string.IsNullOrWhiteSpace(url)) + { + throw new BusinessException("未返回访问地址"); + } + + return new FileStorageAccessUrlOutput + { + Key = FileStoragePath.NormalizeKey(trimmed), + Url = url + }; + } + + /// + /// 签发预签名 PUT,供前端直传。 + /// + [HttpPost("/file-storage/presign-upload")] + public async Task PresignUploadAsync( + [FromBody] FileStoragePresignInput input, + CancellationToken cancellationToken) + { + var folder = (input.Folder ?? "misc").Trim().Trim('/'); + if (string.IsNullOrWhiteSpace(folder) || folder.Contains("..") || folder.Contains('/') || folder.Contains('\\')) + { + throw new BusinessException("folder 无效"); + } + + if (!AllowedFolders.Contains(folder)) + { + throw new BusinessException("不支持的上传目录"); + } + + var fileName = string.IsNullOrWhiteSpace(input.FileName) ? "file.bin" : Path.GetFileName(input.FileName.Trim()); + var ext = Path.GetExtension(fileName); + if (ext.Length > 16) + { + ext = ""; + } + + var objectKey = $"uploads/{folder}/{DateTime.Now:yyyyMM}/{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; + var result = await FileStorage.CreatePresignedUploadAsync( + new ObjectStoragePresignUploadArgs + { + ObjectKey = objectKey, + ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? null : input.ContentType.Trim() + }, + cancellationToken); + + var uploadUrl = result.UploadUrl; + if (string.IsNullOrWhiteSpace(uploadUrl)) + { + throw new BusinessException("预签名上传凭证不完整"); + } + + Dictionary? headers = null; + if (result.Headers is { Count: > 0 }) + { + headers = result.Headers.ToDictionary(x => x.Key, x => x.Value ?? "", StringComparer.OrdinalIgnoreCase); + } + + return new FileStoragePresignOutput + { + Key = string.IsNullOrWhiteSpace(result.ObjectKey) ? objectKey : result.ObjectKey, + UploadUrl = uploadUrl, + Method = string.IsNullOrWhiteSpace(result.Method) ? "PUT" : result.Method, + ContentType = result.ContentType ?? input.ContentType, + Headers = headers, + ExpireAt = FormatExpireAt(result.ExpireAt) + }; + } + + /// + /// 鉴权读取对象字节(面单/附件预览)。key 可为 object key 或 COS 签名 URL。 + /// + [HttpGet("/file-storage/stream")] + public async Task StreamAsync( + [FromQuery] string key, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new BusinessException("key 不能为空"); + } + + var raw = key.Trim(); + foreach (var candidate in FileStoragePath.ExpandReadKeys(raw)) + { + var bytes = await TryReadObjectBytesAsync(candidate, cancellationToken); + if (bytes is { Length: > 0 }) + { + return FileResult(bytes, candidate); + } + } + + if (FileStoragePath.IsAbsoluteUrl(raw)) + { + var fetched = await TryFetchUrlBytesAsync(raw, cancellationToken); + if (fetched is { Length: > 0 }) + { + return FileResult(fetched, raw); + } + } + + throw new BusinessException("文件不存在或无法读取"); + } + + private async Task TryReadObjectBytesAsync(string objectKey, CancellationToken cancellationToken) + { + try + { + if (!await FileStorage.ExistsAsync(objectKey, cancellationToken)) + { + return null; + } + + await using var stream = await FileStorage.GetAsync(objectKey, cancellationToken); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, cancellationToken); + return ms.ToArray(); + } + catch (UnauthorizedAccessException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + catch (FileNotFoundException) + { + return null; + } + catch (IOException) + { + return null; + } + catch (Exception) + { + return null; + } + } + + private async Task TryFetchUrlBytesAsync(string url, CancellationToken cancellationToken) + { + try + { + var client = HttpClientFactory.CreateClient(UrlFetchClientName); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("Accept", "*/*"); + using var response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + catch (Exception) + { + return null; + } + } + + private static FileContentResult FileResult(byte[] bytes, string nameHint) + { + var mime = GuessMime(nameHint); + var path = nameHint.Contains('?', StringComparison.Ordinal) + ? nameHint[..nameHint.IndexOf('?', StringComparison.Ordinal)] + : nameHint; + var fileName = Path.GetFileName(path); + if (string.IsNullOrWhiteSpace(fileName)) + { + fileName = "file"; + } + + return new FileContentResult(bytes, mime) + { + FileDownloadName = fileName + }; + } + + private static string GuessMime(string pathOrUrl) + { + if (Regex.IsMatch(pathOrUrl, @"\.pdf(\?|#|$)", RegexOptions.IgnoreCase)) + { + return "application/pdf"; + } + + if (Regex.IsMatch(pathOrUrl, @"\.png(\?|#|$)", RegexOptions.IgnoreCase)) + { + return "image/png"; + } + + if (Regex.IsMatch(pathOrUrl, @"\.jpe?g(\?|#|$)", RegexOptions.IgnoreCase)) + { + return "image/jpeg"; + } + + if (Regex.IsMatch(pathOrUrl, @"\.webp(\?|#|$)", RegexOptions.IgnoreCase)) + { + return "image/webp"; + } + + if (Regex.IsMatch(pathOrUrl, @"\.gif(\?|#|$)", RegexOptions.IgnoreCase)) + { + return "image/gif"; + } + + return "application/octet-stream"; + } + + private static string? FormatExpireAt(object? expireAt) + { + return expireAt switch + { + DateTimeOffset dto when dto != default => dto.ToString("o"), + DateTime dt when dt != default => dt.ToString("o"), + _ => expireAt?.ToString() + }; + } +} diff --git a/src/Oms.Application/Storage/FileStoragePath.cs b/src/Oms.Application/Storage/FileStoragePath.cs index d0322a8..45cae8f 100644 --- a/src/Oms.Application/Storage/FileStoragePath.cs +++ b/src/Oms.Application/Storage/FileStoragePath.cs @@ -38,4 +38,62 @@ public static class FileStoragePath !string.IsNullOrWhiteSpace(value) && (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)); + + /// + /// 读取对象时尝试的 key:含/不含 admin/ 前缀(自定义域路径常带 PathBase)。 + /// + public static IReadOnlyList ExpandReadKeys(string? pathOrUrl) + { + var keys = new List(); + void Push(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return; + } + + var key = raw.Replace('\\', '/').Trim().TrimStart('/'); + if (key.Length == 0) + { + return; + } + + if (keys.Exists(x => string.Equals(x, key, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + keys.Add(key); + } + + if (IsAbsoluteUrl(pathOrUrl)) + { + try + { + Push(NormalizeKey(pathOrUrl)); + } + catch (ArgumentException) + { + // ignore + } + } + else + { + Push(pathOrUrl); + } + + foreach (var existing in keys.ToArray()) + { + if (existing.StartsWith("admin/", StringComparison.OrdinalIgnoreCase)) + { + Push(existing["admin/".Length..]); + } + else + { + Push("admin/" + existing); + } + } + + return keys; + } } diff --git a/src/Oms.Tests/FileStoragePathTests.cs b/src/Oms.Tests/FileStoragePathTests.cs new file mode 100644 index 0000000..07b0843 --- /dev/null +++ b/src/Oms.Tests/FileStoragePathTests.cs @@ -0,0 +1,19 @@ +using FluentAssertions; +using Oms.Application.Storage; + +namespace Oms.Tests; + +public class FileStoragePathTests +{ + [Fact] + public void ExpandReadKeys_ShouldStripQueryAndTryAdminPrefix() + { + var url = + "https://cos.test.ideship.com/admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf?q-sign-algorithm=sha1"; + + var keys = FileStoragePath.ExpandReadKeys(url); + + keys.Should().Contain("admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf"); + keys.Should().Contain("uploads/prints/orders/IDE2610061059117673-20261006105912.pdf"); + } +} -- 2.45.2 From 8d7df40bdf140976f214c3759a9123308e73c670 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 6 Oct 2026 15:37:03 +0800 Subject: [PATCH 26/41] =?UTF-8?q?feat:=20=E9=94=80=E5=94=AE=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E7=8B=AC=E7=AB=8B=E6=94=B9=E4=BB=A3=E7=A0=81=EF=BC=8C?= =?UTF-8?q?=E5=88=86=E5=8C=BA=E6=98=8E=E7=BB=86=E5=AF=BC=E5=85=A5=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E6=8C=89=E8=A7=84=E5=88=99=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/Channels/KpiAreaInfoImportOutput.cs | 2 + .../Channels/SalesProductUpdateCodeInput.cs | 21 +++++++ .../Services/Channels/KpiAreaService.cs | 56 +++++++++++++++++++ .../Channels/SalesProductService.Helpers.cs | 30 +++++++++- .../Services/Channels/SalesProductService.cs | 32 ++++++++--- .../Shared/GeoPartitionMatcher.cs | 27 +++++++++ src/Oms.Tests/GeoPartitionMatcherTests.cs | 49 ++++++++++++++++ 7 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs index 5927568..db2cd35 100644 --- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs +++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs @@ -7,6 +7,8 @@ public class KpiAreaInfoImportOutput { public int SuccessCount { get; set; } + public int SkipCount { get; set; } + public int FailCount { get; set; } public List Failures { get; set; } = []; diff --git a/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs new file mode 100644 index 0000000..ec05ce5 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Channels; + +/// +/// 单独修改销售产品代码入参。 +/// +public class SalesProductUpdateCodeInput +{ + /// + /// 销售产品主键。 + /// + [Required] + public int Id { get; set; } + + /// + /// 新的产品代码,仅大写字母、数字、-。 + /// + [Required] + public string ProductCode { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs index 7c76aa1..4a2d60f 100644 --- a/src/Oms.Application/Services/Channels/KpiAreaService.cs +++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs @@ -136,6 +136,7 @@ public class KpiAreaService : ApplicationService PrefixLen = input.PrefixLen ?? 0 }; PrepareAreaInfo(entity); + await EnsureInfoNotDuplicateAsync(entity, excludeId: null); await KpiAreaRepository.InsertInfoAsync(entity); } @@ -160,6 +161,7 @@ public class KpiAreaService : ApplicationService entity.MatchMode = input.MatchMode; entity.PrefixLen = input.PrefixLen ?? 0; PrepareAreaInfo(entity); + await EnsureInfoNotDuplicateAsync(entity, excludeId: entity.Id); await KpiAreaRepository.UpdateInfoAsync(entity); } @@ -214,6 +216,12 @@ public class KpiAreaService : ApplicationService .GroupBy(x => x.CountryCode.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var existingKeys = new HashSet(StringComparer.Ordinal); + foreach (var item in await KpiAreaRepository.GetAllInfosByAreaIdAsync(input.AreaId)) + { + TryAddDuplicateKey(existingKeys, item); + } + var output = new KpiAreaInfoImportOutput(); foreach (var (rowNumber, entity) in parsedRows) { @@ -221,6 +229,17 @@ public class KpiAreaService : ApplicationService { KpiAreaInfoImportHelper.PrepareEntityForImport(entity, countriesByCode); entity.AreaId = input.AreaId; + if (!TryAddDuplicateKey(existingKeys, entity)) + { + output.SkipCount++; + output.Failures.Add(new KpiAreaInfoImportFailRow + { + RowNumber = rowNumber, + Reason = "与已有规则重复,已跳过" + }); + continue; + } + await KpiAreaRepository.InsertInfoAsync(entity); output.SuccessCount++; } @@ -314,6 +333,43 @@ public class KpiAreaService : ApplicationService } } + private async Task EnsureInfoNotDuplicateAsync(KpiAreaInfo entity, int? excludeId) + { + var key = GeoPartitionMatcher.BuildDuplicateKey(entity); + var existing = await KpiAreaRepository.GetAllInfosByAreaIdAsync(entity.AreaId); + foreach (var item in existing) + { + if (excludeId.HasValue && item.Id == excludeId.Value) + { + continue; + } + + if (TryBuildDuplicateKey(item, out var existingKey) + && string.Equals(existingKey, key, StringComparison.Ordinal)) + { + throw new BusinessException("分区明细已存在相同规则"); + } + } + } + + private static bool TryAddDuplicateKey(HashSet keys, KpiAreaInfo entity) => + TryBuildDuplicateKey(entity, out var key) && keys.Add(key); + + private static bool TryBuildDuplicateKey(KpiAreaInfo entity, out string key) + { + try + { + GeoPartitionMatcher.PrepareEntity(entity); + key = GeoPartitionMatcher.BuildDuplicateKey(entity); + return true; + } + catch (InvalidOperationException) + { + key = string.Empty; + return false; + } + } + private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity) { var (matchDim, matchMode, prefixLen) = GeoPartitionMatcher.Describe(entity); diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 5c3203d..4deba9e 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -11,6 +11,29 @@ namespace Oms.Application.Services.Channels; public partial class SalesProductService { + /// + /// 规范化并校验产品代码格式。 + /// + private static string NormalizeProductCode(string? productCode) + { + var code = (productCode ?? string.Empty).Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(code)) + { + throw new Fake.Application.BusinessException("产品代码不能为空"); + } + + for (var i = 0; i < code.Length; i++) + { + var ch = code[i]; + if (ch is < 'A' or > 'Z' && ch is < '0' or > '9' && ch != '-') + { + throw new Fake.Application.BusinessException("产品代码仅支持大写字母、数字和-"); + } + } + + return code; + } + /// /// 校验产品编码唯一性。 /// @@ -344,9 +367,12 @@ public partial class SalesProductService /// /// 将保存入参映射到销售产品实体。 /// - private static void ApplyProductInput(SalesProduct entity, SalesProductSaveInput input) + private static void ApplyProductInput(SalesProduct entity, SalesProductSaveInput input, bool updateProductCode = true) { - entity.ProductCode = input.ProductCode; + if (updateProductCode) + { + entity.ProductCode = NormalizeProductCode(input.ProductCode); + } entity.ProductName = input.ProductName; entity.ProductGroup = input.ProductGroup; entity.MinTimeliness = input.MinTimeliness; diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index 9ebf67f..5f75ce2 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -74,8 +74,8 @@ public partial class SalesProductService : ApplicationService [HttpPost("/channel/sales-product/create")] public async Task CreateAsync(SalesProductSaveInput input) { - await ValidateProductCodeAsync(input.ProductCode, null); - var code = input.ProductCode.Trim(); + var code = NormalizeProductCode(input.ProductCode); + await ValidateProductCodeAsync(code, null); var deleted = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(code); if (deleted != null && deleted.IsDeleted) { @@ -99,9 +99,28 @@ public partial class SalesProductService : ApplicationService public async Task UpdateAsync(SalesProductSaveInput input) { var entity = await GetProductOrThrowAsync(input.Id); - await ValidateProductCodeAsync(input.ProductCode, entity.Id); - await FreeSoftDeletedProductCodeIfNeededAsync(input.ProductCode.Trim(), entity.Id); - ApplyProductInput(entity, input); + ApplyProductInput(entity, input, updateProductCode: false); + await SalesProductRepository.UpdateAsync(entity); + } + + /// + /// 单独修改销售产品代码。 + /// + /// 入参:产品主键与新代码。 + /// 无返回体,仅完成产品代码更新。 + [HttpPost("/channel/sales-product/product-code/update")] + public async Task UpdateProductCodeAsync(SalesProductUpdateCodeInput input) + { + var entity = await GetProductOrThrowAsync(input.Id); + var code = NormalizeProductCode(input.ProductCode); + if (string.Equals(entity.ProductCode, code, StringComparison.Ordinal)) + { + return; + } + + await ValidateProductCodeAsync(code, entity.Id); + await FreeSoftDeletedProductCodeIfNeededAsync(code, entity.Id); + entity.ProductCode = code; await SalesProductRepository.UpdateAsync(entity); } @@ -127,8 +146,7 @@ public partial class SalesProductService : ApplicationService public async Task SaveConfigAsync(SalesProductSaveInput input) { var entity = await GetProductOrThrowAsync(input.Id); - await ValidateProductCodeAsync(input.ProductCode, entity.Id); - ApplyProductInput(entity, input); + ApplyProductInput(entity, input, updateProductCode: false); await SalesProductRepository.UpdateAsync(entity); } diff --git a/src/Oms.Application/Shared/GeoPartitionMatcher.cs b/src/Oms.Application/Shared/GeoPartitionMatcher.cs index 4eab0fd..ace4b34 100644 --- a/src/Oms.Application/Shared/GeoPartitionMatcher.cs +++ b/src/Oms.Application/Shared/GeoPartitionMatcher.cs @@ -409,6 +409,33 @@ public static class GeoPartitionMatcher } } + /// + /// 同一分区内判定完全相同规则的指纹(不比较 AreaId)。 + /// 须在 PrepareEntity 之后调用。 + /// + public static string BuildDuplicateKey(KpiAreaInfo entity) + { + ArgumentNullException.ThrowIfNull(entity); + var dim = NormalizeDim(entity.MatchDim) ?? DimCountry; + var mode = NormalizeMode(entity.MatchMode) ?? ModeAll; + var country = NormKey(entity.CountryCode); + + return dim switch + { + DimState => $"{DimState}|{country}|{NormKey(entity.Province)}", + DimCity => $"{DimCity}|{country}|{NormKey(entity.Province)}|{NormKey(entity.City)}", + DimZip when mode == ModePrefix => + $"{DimZip}|{ModePrefix}|{country}|{NormKey(entity.LeftPostcode)}|{entity.PrefixLen}", + DimZip when mode == ModeExact => + $"{DimZip}|{ModeExact}|{country}|{NormKey(entity.LeftPostcode)}", + DimZip => + $"{DimZip}|{ModeRange}|{country}|{NormKey(entity.LeftPostcode)}|{NormKey(entity.RightPostcode)}", + _ => $"{DimCountry}|{country}" + }; + } + + private static string NormKey(string? value) => (value ?? string.Empty).Trim().ToUpperInvariant(); + public static (string MatchDim, string MatchMode, int PrefixLen) Describe(KpiAreaInfo? entity) { if (entity == null) diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs index e2a1afc..5d10a15 100644 --- a/src/Oms.Tests/GeoPartitionMatcherTests.cs +++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs @@ -254,6 +254,55 @@ public class GeoPartitionMatcherTests GeoPartitionMatcher.MatchEntity(rules, "GB", "NIR", "Belfast", null)!.Province.Should().Be("NIR"); } + [Fact] + public void BuildDuplicateKey_SameCategory_ShouldMatchIgnoringCase() + { + var a = CountryOnly("us"); + var b = CountryOnly("US"); + GeoPartitionMatcher.PrepareEntity(a); + GeoPartitionMatcher.PrepareEntity(b); + GeoPartitionMatcher.BuildDuplicateKey(a).Should().Be(GeoPartitionMatcher.BuildDuplicateKey(b)); + } + + [Fact] + public void BuildDuplicateKey_DifferentDims_ShouldNotCollide() + { + var country = CountryOnly("US"); + var state = State("US", "CA"); + var zip = Zip("US", "90"); + GeoPartitionMatcher.PrepareEntity(country); + GeoPartitionMatcher.PrepareEntity(state); + GeoPartitionMatcher.PrepareEntity(zip); + + var keys = new[] + { + GeoPartitionMatcher.BuildDuplicateKey(country), + GeoPartitionMatcher.BuildDuplicateKey(state), + GeoPartitionMatcher.BuildDuplicateKey(zip) + }; + keys.Should().OnlyHaveUniqueItems(); + } + + [Fact] + public void BuildDuplicateKey_SameZipRange_ShouldMatch() + { + var a = Range("US", "90000", "90199"); + var b = Range("us", "90000", "90199"); + GeoPartitionMatcher.PrepareEntity(a); + GeoPartitionMatcher.PrepareEntity(b); + GeoPartitionMatcher.BuildDuplicateKey(a).Should().Be(GeoPartitionMatcher.BuildDuplicateKey(b)); + } + + [Fact] + public void BuildDuplicateKey_PrefixVsExact_ShouldNotCollide() + { + var prefix = Zip("US", "90"); + var exact = Exact("US", "90"); + GeoPartitionMatcher.PrepareEntity(prefix); + GeoPartitionMatcher.PrepareEntity(exact); + GeoPartitionMatcher.BuildDuplicateKey(prefix).Should().NotBe(GeoPartitionMatcher.BuildDuplicateKey(exact)); + } + private static List CompileEntities(params KpiAreaInfo[] items) { foreach (var item in items) -- 2.45.2 From f972a28d79e2326527169e456b7c8f63a4dba437 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 6 Oct 2026 17:40:24 +0800 Subject: [PATCH 27/41] =?UTF-8?q?feat:=20=E8=90=BD=E5=9C=B0=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E4=BC=9A=E5=91=98=E3=80=81=E5=88=B8=E5=8F=91=E6=94=BE?= =?UTF-8?q?=E4=B8=8E=E9=82=80=E8=AF=B7=E5=88=86=E6=88=90=E5=A5=96=E5=8A=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- sql/README.md | 2 +- sql/migrate_allocation_match_condition.sql | 21 -- sql/migrate_coupon_grant_system.sql | 109 ++++++++ sql/migrate_customer_export_qualification.sql | 98 ------- sql/migrate_customer_openapi_credential.sql | 41 --- sql/migrate_declare_element.sql | 38 --- sql/migrate_issue_template_handler.sql | 45 --- sql/migrate_member_level_rolling_points.sql | 216 +++++++++++++++ sql/migrate_order_export_qualification_id.sql | 24 -- ...rate_pickup_direction_and_address_type.sql | 16 -- sql/migrate_pickup_fee_push.sql | 11 - sql/migrate_referral_invite_reward.sql | 97 +++++++ .../Dtos/Marketing/CommissionDtos.cs | 40 +++ .../Dtos/Marketing/CouponDtos.cs | 8 + .../Dtos/Marketing/MarketingPriceBreakdown.cs | 4 + .../Dtos/Marketing/MemberLevelOutput.cs | 3 + .../Dtos/Marketing/MemberLevelSaveInput.cs | 2 + .../Dtos/Marketing/MemberOverviewOutput.cs | 50 ++-- .../Dtos/Marketing/ReferralDtos.cs | 260 +++++++++--------- .../Dtos/Orders/OrderRefundInput.cs | 14 + .../Services/Customers/CustomerService.cs | 8 +- .../MarketingCommissionAdminService.cs | 2 + .../MarketingCommissionRuleService.cs | 47 +++- .../Marketing/MarketingCouponGrantService.cs | 6 +- .../MarketingCouponTemplateService.cs | 6 + .../Marketing/MarketingMemberLevelService.cs | 6 +- .../MarketingPortalCommissionService.cs | 2 + .../Marketing/MarketingPortalCouponService.cs | 3 +- .../Marketing/MarketingPortalMemberService.cs | 6 +- .../MarketingPortalReferralService.cs | 63 ++++- .../Orders/OrderService.ParcelBilling.cs | 21 +- .../Orders/OrderService.ParcelQuote.cs | 12 +- .../Services/Orders/OrderService.cs | 64 ++++- .../Orders/ParcelBillingCalculator.cs | 7 +- .../Shared/CustomerBalanceMutator.cs | 52 ++++ .../Shared/MarketingBillingCalculator.cs | 124 +++++++-- .../Shared/MarketingCouponManager.cs | 239 +++++++++++++++- .../Shared/MarketingCouponScope.cs | 52 ++++ .../Shared/MarketingCustomerHelper.cs | 4 +- .../Shared/MarketingEnumNames.cs | 4 + .../Shared/MarketingFeeHelper.cs | 33 +++ .../Shared/MarketingInviteRewardCalculator.cs | 141 ++++++++++ .../Shared/MarketingOrderHelper.cs | 48 +++- .../Shared/MarketingPointsManager.cs | 228 ++++++++++----- .../Shared/MarketingReferralBinder.cs | 28 ++ .../Shared/ReferralCommissionManager.cs | 196 +++++++++---- .../Workers/MarketingBackgroundWorker.cs | 22 +- .../Consts/MarketingConsts.cs | 19 ++ .../Entities/CouponInstance.cs | 2 + .../Entities/MemberLevel.cs | 2 + .../Entities/ReferralCommissionDetail.cs | 4 + .../Entities/ReferralCommissionRule.cs | 18 ++ .../Enums/MarketingEnums.cs | 4 +- .../Repositories/IMarketingRepositories.cs | 21 ++ .../OrdersAggregate/Entities/Order.cs | 2 + .../Marketing/MarketingRepositories.cs | 98 ++++++- .../MarketingInviteRewardCalculatorTests.cs | 76 +++++ src/Oms.Tests/MarketingMemberPointsTests.cs | 200 ++++++++++++++ .../WeChat/WeChatCustomerService.cs | 6 + 59 files changed, 2323 insertions(+), 652 deletions(-) delete mode 100644 sql/migrate_allocation_match_condition.sql create mode 100644 sql/migrate_coupon_grant_system.sql delete mode 100644 sql/migrate_customer_export_qualification.sql delete mode 100644 sql/migrate_customer_openapi_credential.sql delete mode 100644 sql/migrate_declare_element.sql delete mode 100644 sql/migrate_issue_template_handler.sql create mode 100644 sql/migrate_member_level_rolling_points.sql delete mode 100644 sql/migrate_order_export_qualification_id.sql delete mode 100644 sql/migrate_pickup_direction_and_address_type.sql delete mode 100644 sql/migrate_pickup_fee_push.sql create mode 100644 sql/migrate_referral_invite_reward.sql create mode 100644 src/Oms.Application/Dtos/Orders/OrderRefundInput.cs create mode 100644 src/Oms.Application/Shared/MarketingCouponScope.cs create mode 100644 src/Oms.Application/Shared/MarketingFeeHelper.cs create mode 100644 src/Oms.Application/Shared/MarketingInviteRewardCalculator.cs create mode 100644 src/Oms.Tests/MarketingInviteRewardCalculatorTests.cs create mode 100644 src/Oms.Tests/MarketingMemberPointsTests.cs diff --git a/sql/README.md b/sql/README.md index ddb4def..9d84b35 100644 --- a/sql/README.md +++ b/sql/README.md @@ -3,5 +3,5 @@ OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。 - 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行) -- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录下单个表脚本(如 `c_parcel_arrival.sql`、`c_parcel_item_AttributeText.sql`、`c_parcel_item_ImageUrl.sql`)可直接在已有库执行增量建表;若仍为旧名 `C_*`,先执行 `migrate_rename_warehouse_C_to_c.sql` +- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过) - 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落 diff --git a/sql/migrate_allocation_match_condition.sql b/sql/migrate_allocation_match_condition.sql deleted file mode 100644 index 274f2e0..0000000 --- a/sql/migrate_allocation_match_condition.sql +++ /dev/null @@ -1,21 +0,0 @@ --- 存量库:排货规则服务商表增加判断条件 MatchCondition --- 可重复执行;仅在列不存在时 ADD。 --- 目标表:t_sales_product_allocation_provider - -SET @db := DATABASE(); - -SET @exists := ( - SELECT COUNT(1) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_sales_product_allocation_provider' - AND COLUMN_NAME = 'MatchCondition' -); -SET @sql := IF( - @exists = 0, - 'ALTER TABLE `t_sales_product_allocation_provider` ADD COLUMN `MatchCondition` TEXT NULL COMMENT ''排货判断条件(中文公式),空表示全部订单可用'' AFTER `CustomerIds`', - 'SELECT ''MatchCondition already exists'' AS info' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_coupon_grant_system.sql b/sql/migrate_coupon_grant_system.sql new file mode 100644 index 0000000..f5b5419 --- /dev/null +++ b/sql/migrate_coupon_grant_system.sql @@ -0,0 +1,109 @@ +-- 优惠券发放体系:未解锁状态、到期提醒标记、新人/裂变券模板(可重复执行) +SET @db := DATABASE(); + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_coupon_instance' + AND COLUMN_NAME = 'ExpiryNoticeSent' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_coupon_instance` + ADD COLUMN `ExpiryNoticeSent` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''到期前3天已提醒'' AFTER `LockExpireTime`', + 'SELECT ''ExpiryNoticeSent already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 新人首单券 15 元,领取后 15 天 +INSERT INTO `t_coupon_template` ( + `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`, + `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`, + `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`, + `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`, + `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime` +) +SELECT + 'NEWCOMER_FIRST', '新人首单券', 'fixed_off', 15.00, NULL, NULL, + 0, 0, 0, 1, + 2, NULL, NULL, 15, + NULL, 'newcomer', 1, '注册自动下发,仅抵基础运费,退款不退回', + 0, 0, NOW(), 0, NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_FIRST' +); + +-- 新人第二单券 15 元,满 150 基础运费,解锁后 30 天 +INSERT INTO `t_coupon_template` ( + `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`, + `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`, + `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`, + `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`, + `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime` +) +SELECT + 'NEWCOMER_SECOND', '新人第二单券', 'fixed_off', 15.00, NULL, NULL, + 150, 0, 0, 1, + 3, NULL, NULL, 30, + NULL, 'newcomer', 1, '首单派送成功后解锁,实付基础运费满 150', + 0, 0, NOW(), 0, NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_SECOND' +); + +-- 新人备货券 20 元,满 200,注册后 60 天 +INSERT INTO `t_coupon_template` ( + `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`, + `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`, + `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`, + `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`, + `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime` +) +SELECT + 'NEWCOMER_STOCK', '新人备货券', 'fixed_off', 20.00, NULL, NULL, + 200, 0, 0, 1, + 2, NULL, NULL, 60, + NULL, 'newcomer', 1, '注册自动下发,实付基础运费满 200', + 0, 0, NOW(), 0, NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_STOCK' +); + +-- 裂变:老客 20 元无门槛 +INSERT INTO `t_coupon_template` ( + `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`, + `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`, + `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`, + `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`, + `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime` +) +SELECT + 'REFERRAL_INVITER', '邀请有礼券', 'fixed_off', 20.00, NULL, NULL, + 0, 0, 0, 1, + 2, NULL, NULL, 30, + NULL, 'referral', 1, '被邀请人首单派送成功后发给邀请人', + 0, 0, NOW(), 0, NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'REFERRAL_INVITER' +); + +-- 裂变:新客额外 5 元 +INSERT INTO `t_coupon_template` ( + `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`, + `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`, + `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`, + `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`, + `IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime` +) +SELECT + 'REFERRAL_INVITEE', '邀请注册加赠券', 'fixed_off', 5.00, NULL, NULL, + 0, 0, 0, 1, + 2, NULL, NULL, 30, + NULL, 'referral', 1, '被邀请人注册即发 5 元券', + 0, 0, NOW(), 0, NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'REFERRAL_INVITEE' +); diff --git a/sql/migrate_customer_export_qualification.sql b/sql/migrate_customer_export_qualification.sql deleted file mode 100644 index efd88fe..0000000 --- a/sql/migrate_customer_export_qualification.sql +++ /dev/null @@ -1,98 +0,0 @@ --- 存量库:客户出口资质备案表(含 IsDefault / Remark) --- 可重复执行。 --- 目标表:t_customer_export_qualification - -SET @db := DATABASE(); - --- 1) 表不存在则整表创建 -SET @exists := ( - SELECT COUNT(1) - FROM information_schema.TABLES - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_customer_export_qualification' -); - -SET @sql := IF( - @exists = 0, - 'CREATE TABLE `t_customer_export_qualification` ( - `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', - `CustomerId` INT NOT NULL COMMENT ''归属客户 t_customer.Id'', - `CompanyName` VARCHAR(200) NOT NULL COMMENT ''企业名称'', - `SocialCreditCode` VARCHAR(64) NOT NULL COMMENT ''企业社会信用代码'', - `DomesticConsignor` VARCHAR(200) NOT NULL COMMENT ''境内发货人'', - `DomesticConsignorCustomsCode` VARCHAR(64) NULL COMMENT ''境内发货人海关代码'', - `ProducerName` VARCHAR(200) NOT NULL COMMENT ''生产销售单位'', - `ProducerCustomsCode` VARCHAR(64) NULL COMMENT ''生产销售单位海关代码'', - `CustomsPort` VARCHAR(100) NULL COMMENT ''备案海关港口地'', - `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'', - `Remark` VARCHAR(512) NULL COMMENT ''备注'', - `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', - `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', - `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', - `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', - `UpdateUserId` INT NULL COMMENT ''更新人'', - `UpdateTime` DATETIME NULL COMMENT ''更新时间'', - PRIMARY KEY (`Id`), - KEY `idx_ceq_customer` (`CustomerId`, `IsDeleted`, `Id`), - KEY `idx_ceq_credit` (`SocialCreditCode`, `IsDeleted`), - KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`) -) COMMENT=''客户出口资质备案''', - 'SELECT ''t_customer_export_qualification already exists'' AS info' -); - -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 2) 已建表但缺 IsDefault 时补列 -SET @col := ( - SELECT COUNT(1) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_customer_export_qualification' - AND COLUMN_NAME = 'IsDefault' -); -SET @sql := IF( - @col = 0, - 'ALTER TABLE `t_customer_export_qualification` - ADD COLUMN `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'' AFTER `CustomsPort`', - 'SELECT ''IsDefault already exists'' AS info' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 3) 已建表但缺 Remark 时补列 -SET @col := ( - SELECT COUNT(1) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_customer_export_qualification' - AND COLUMN_NAME = 'Remark' -); -SET @sql := IF( - @col = 0, - 'ALTER TABLE `t_customer_export_qualification` - ADD COLUMN `Remark` VARCHAR(512) NULL COMMENT ''备注'' AFTER `IsDefault`', - 'SELECT ''Remark already exists'' AS info' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 4) 默认索引 -SET @idx := ( - SELECT COUNT(1) - FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_customer_export_qualification' - AND INDEX_NAME = 'idx_ceq_customer_default' -); -SET @sql := IF( - @idx = 0, - 'ALTER TABLE `t_customer_export_qualification` ADD KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)', - 'SELECT ''idx_ceq_customer_default already exists'' AS info' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_customer_openapi_credential.sql b/sql/migrate_customer_openapi_credential.sql deleted file mode 100644 index da9e138..0000000 --- a/sql/migrate_customer_openapi_credential.sql +++ /dev/null @@ -1,41 +0,0 @@ --- 客户 OpenAPI 机机凭证(一客户一对 AppKey/AppSecret,独立于 itfx) --- 可重复执行。 --- 目标表:t_customer_openapi_credential - -SET @db := DATABASE(); - -SET @exists := ( - SELECT COUNT(1) - FROM information_schema.TABLES - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_customer_openapi_credential' -); - -SET @sql := IF( - @exists = 0, - 'CREATE TABLE `t_customer_openapi_credential` ( - `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', - `CustomerId` INT NOT NULL COMMENT ''归属主客户 t_customer.Id'', - `AppKey` VARCHAR(64) NOT NULL COMMENT ''开放接口 AppKey'', - `AppSecret` VARCHAR(128) NOT NULL COMMENT ''开放接口 AppSecret(明文,创建/重置时返回)'', - `Enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:0-否,1-是'', - `LastUsedAt` DATETIME NULL COMMENT ''最近鉴权成功时间'', - `LastUsedIp` VARCHAR(64) NULL COMMENT ''最近鉴权成功 IP'', - `Remark` VARCHAR(512) NULL COMMENT ''备注'', - `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', - `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', - `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', - `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', - `UpdateUserId` INT NULL COMMENT ''更新人'', - `UpdateTime` DATETIME NULL COMMENT ''更新时间'', - PRIMARY KEY (`Id`), - UNIQUE KEY `uk_coc_customer` (`CustomerId`), - UNIQUE KEY `uk_coc_app_key` (`AppKey`), - KEY `idx_coc_enabled` (`Enabled`, `IsDeleted`) -) COMMENT=''客户 OpenAPI 机机凭证''', - 'SELECT ''t_customer_openapi_credential already exists'' AS info' -); - -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_declare_element.sql b/sql/migrate_declare_element.sql deleted file mode 100644 index 62f59fd..0000000 --- a/sql/migrate_declare_element.sql +++ /dev/null @@ -1,38 +0,0 @@ --- 存量库:SKU / 订单申报增加「出口申报要素」DeclareElement --- 可重复执行;仅在列不存在时 ADD。 - -SET @db := DATABASE(); - --- c_sku.DeclareElement -SET @exists := ( - SELECT COUNT(1) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 'c_sku' - AND COLUMN_NAME = 'DeclareElement' -); -SET @sql := IF( - @exists = 0, - 'ALTER TABLE `c_sku` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ImHsCode`', - 'SELECT ''c_sku.DeclareElement already exists'' AS info' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- t_order_declaration.DeclareElement -SET @exists := ( - SELECT COUNT(1) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_order_declaration' - AND COLUMN_NAME = 'DeclareElement' -); -SET @sql := IF( - @exists = 0, - 'ALTER TABLE `t_order_declaration` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ExHsCode`', - 'SELECT ''t_order_declaration.DeclareElement already exists'' AS info' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_issue_template_handler.sql b/sql/migrate_issue_template_handler.sql deleted file mode 100644 index 409cdb7..0000000 --- a/sql/migrate_issue_template_handler.sql +++ /dev/null @@ -1,45 +0,0 @@ --- 存量库:为问题模板补充处理人字段(与 db.sql / IssueTemplate 实体对齐) --- 可重复执行;仅在列不存在时 ADD。 --- 目标表:t_issue_template - -SET @db := DATABASE(); - --- HandlerType -SET @exists := ( - SELECT COUNT(1) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_issue_template' - AND COLUMN_NAME = 'HandlerType' -); -SET @sql := IF( - @exists = 0, - 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT ''处理人类型:customer=订单所属客户,user=指定用户'' AFTER `NotifyMethod`', - 'SELECT ''HandlerType already exists'' AS info' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- HandlerUserId -SET @exists := ( - SELECT COUNT(1) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_issue_template' - AND COLUMN_NAME = 'HandlerUserId' -); -SET @sql := IF( - @exists = 0, - 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerUserId` INT NULL COMMENT ''指定处理人用户Id(HandlerType=user 时必填)'' AFTER `HandlerType`', - 'SELECT ''HandlerUserId already exists'' AS info' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 历史数据默认按「指定用户」语义;无处理人时可在页面再编辑 -UPDATE `t_issue_template` -SET `HandlerType` = 'user' -WHERE (`HandlerType` IS NULL OR `HandlerType` = '') - AND `IsDeleted` = 0; diff --git a/sql/migrate_member_level_rolling_points.sql b/sql/migrate_member_level_rolling_points.sql new file mode 100644 index 0000000..ee8c2cf --- /dev/null +++ b/sql/migrate_member_level_rolling_points.sql @@ -0,0 +1,216 @@ +-- 旧会员体系 → V0~V5(可重复执行) +-- old: normal/silver/gold/diamond +-- new: V0 普通 / V1 铜卡(新增) / V2 银卡 / V3 金卡 / V4 钻石 / V5 皇冠(新增) +SET @db := DATABASE(); + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_member_level' + AND COLUMN_NAME = 'CanDowngrade' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_member_level` + ADD COLUMN `CanDowngrade` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''V4/V5 允许逐级降级'' AFTER `ExtraDiscountRate`', + 'SELECT ''CanDowngrade already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ---------- 把旧行升级成新编码(仅当目标 V* 还不存在)---------- +UPDATE `t_member_level` +SET + `LevelCode` = 'V0', + `LevelName` = '普通会员', + `MinPoints` = 0, + `ExtraDiscountRate` = 0.00, + `CanDowngrade` = 0, + `Sort` = 0, + `BenefitDesc` = '新注册 / 小单试单卖家', + `Status` = 2, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 + AND LOWER(`LevelCode`) = 'normal' + AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V0') t); + +UPDATE `t_member_level` +SET + `LevelCode` = 'V2', + `LevelName` = '银卡会员', + `MinPoints` = 60000, + `ExtraDiscountRate` = 0.02, + `CanDowngrade` = 0, + `Sort` = 2, + `BenefitDesc` = '主力中小卖家,基础运费 98 折', + `Status` = 2, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 + AND LOWER(`LevelCode`) = 'silver' + AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V2') t); + +UPDATE `t_member_level` +SET + `LevelCode` = 'V3', + `LevelName` = '金卡会员', + `MinPoints` = 120000, + `ExtraDiscountRate` = 0.03, + `CanDowngrade` = 0, + `Sort` = 3, + `BenefitDesc` = '优质中型卖家,基础运费 97 折;此级及以下只升不降', + `Status` = 2, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 + AND LOWER(`LevelCode`) = 'gold' + AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V3') t); + +UPDATE `t_member_level` +SET + `LevelCode` = 'V4', + `LevelName` = '钻石会员', + `MinPoints` = 240000, + `ExtraDiscountRate` = 0.04, + `CanDowngrade` = 1, + `Sort` = 4, + `BenefitDesc` = '中大卖家,基础运费 96 折;滚动积分不足可降到金卡', + `Status` = 2, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 + AND LOWER(`LevelCode`) = 'diamond' + AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V4') t); + +-- ---------- 缺档则插入(铜卡、皇冠,以及未被旧行带过来的档)---------- +INSERT INTO `t_member_level` + (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`) +SELECT 'V0', '普通会员', 0, 0.00, 0, 0, '新注册 / 小单试单卖家', 2, 0, NOW() +WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V0' AND `IsDeleted` = 0); + +INSERT INTO `t_member_level` + (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`) +SELECT 'V1', '铜卡会员', 24000, 0.01, 0, 1, '起步稳定发货卖家,基础运费 99 折', 2, 0, NOW() +WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V1' AND `IsDeleted` = 0); + +INSERT INTO `t_member_level` + (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`) +SELECT 'V2', '银卡会员', 60000, 0.02, 0, 2, '主力中小卖家,基础运费 98 折', 2, 0, NOW() +WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V2' AND `IsDeleted` = 0); + +INSERT INTO `t_member_level` + (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`) +SELECT 'V3', '金卡会员', 120000, 0.03, 0, 3, '优质中型卖家,基础运费 97 折;此级及以下只升不降', 2, 0, NOW() +WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V3' AND `IsDeleted` = 0); + +INSERT INTO `t_member_level` + (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`) +SELECT 'V4', '钻石会员', 240000, 0.04, 1, 4, '中大卖家,基础运费 96 折;滚动积分不足可降到金卡', 2, 0, NOW() +WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V4' AND `IsDeleted` = 0); + +INSERT INTO `t_member_level` + (`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`) +SELECT 'V5', '皇冠会员', 600000, 0.05, 1, 5, '顶级头部卖家,基础运费 95 折封顶;滚动积分不足可降到钻石', 2, 0, NOW() +WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V5' AND `IsDeleted` = 0); + +-- ---------- 旧编码与新档并存时:客户挂到新档,旧行停用 ---------- +UPDATE `t_customer` c +INNER JOIN `t_member_level` old ON old.`Id` = c.`MemberLevelId` AND old.`IsDeleted` = 0 +INNER JOIN `t_member_level` canon ON canon.`IsDeleted` = 0 AND canon.`LevelCode` = CASE LOWER(old.`LevelCode`) + WHEN 'normal' THEN 'V0' + WHEN 'silver' THEN 'V2' + WHEN 'gold' THEN 'V3' + WHEN 'diamond' THEN 'V4' + ELSE NULL +END +SET c.`MemberLevelId` = canon.`Id`, c.`UpdateTime` = NOW() +WHERE LOWER(old.`LevelCode`) IN ('normal', 'silver', 'gold', 'diamond') + AND old.`Id` <> canon.`Id`; + +UPDATE `t_member_level` old +INNER JOIN `t_member_level` canon ON canon.`IsDeleted` = 0 AND canon.`LevelCode` = CASE LOWER(old.`LevelCode`) + WHEN 'normal' THEN 'V0' + WHEN 'silver' THEN 'V2' + WHEN 'gold' THEN 'V3' + WHEN 'diamond' THEN 'V4' + ELSE NULL +END +SET + old.`IsDeleted` = 1, + old.`Status` = 1, + old.`UpdateTime` = NOW() +WHERE old.`IsDeleted` = 0 + AND LOWER(old.`LevelCode`) IN ('normal', 'silver', 'gold', 'diamond') + AND old.`Id` <> canon.`Id`; + +-- ---------- 校正 V0~V5 门槛/折扣(已占用客户的行 Id 不变)---------- +UPDATE `t_member_level` +SET + `LevelName` = '普通会员', + `MinPoints` = 0, + `ExtraDiscountRate` = 0.00, + `CanDowngrade` = 0, + `Sort` = 0, + `BenefitDesc` = '新注册 / 小单试单卖家', + `Status` = 2, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 AND `LevelCode` = 'V0'; + +UPDATE `t_member_level` +SET + `LevelName` = '铜卡会员', + `MinPoints` = 24000, + `ExtraDiscountRate` = 0.01, + `CanDowngrade` = 0, + `Sort` = 1, + `BenefitDesc` = '起步稳定发货卖家,基础运费 99 折', + `Status` = 2, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 AND `LevelCode` = 'V1'; + +UPDATE `t_member_level` +SET + `LevelName` = '银卡会员', + `MinPoints` = 60000, + `ExtraDiscountRate` = 0.02, + `CanDowngrade` = 0, + `Sort` = 2, + `BenefitDesc` = '主力中小卖家,基础运费 98 折', + `Status` = 2, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 AND `LevelCode` = 'V2'; + +UPDATE `t_member_level` +SET + `LevelName` = '金卡会员', + `MinPoints` = 120000, + `ExtraDiscountRate` = 0.03, + `CanDowngrade` = 0, + `Sort` = 3, + `BenefitDesc` = '优质中型卖家,基础运费 97 折;此级及以下只升不降', + `Status` = 2, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 AND `LevelCode` = 'V3'; + +UPDATE `t_member_level` +SET + `LevelName` = '钻石会员', + `MinPoints` = 240000, + `ExtraDiscountRate` = 0.04, + `CanDowngrade` = 1, + `Sort` = 4, + `BenefitDesc` = '中大卖家,基础运费 96 折;滚动积分不足可降到金卡', + `Status` = 2, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 AND `LevelCode` = 'V4'; + +UPDATE `t_member_level` +SET + `LevelName` = '皇冠会员', + `MinPoints` = 600000, + `ExtraDiscountRate` = 0.05, + `CanDowngrade` = 1, + `Sort` = 5, + `BenefitDesc` = '顶级头部卖家,基础运费 95 折封顶;滚动积分不足可降到钻石', + `Status` = 2, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 AND `LevelCode` = 'V5'; diff --git a/sql/migrate_order_export_qualification_id.sql b/sql/migrate_order_export_qualification_id.sql deleted file mode 100644 index 7cb5d9b..0000000 --- a/sql/migrate_order_export_qualification_id.sql +++ /dev/null @@ -1,24 +0,0 @@ --- 存量库:t_order 增加出口资质备案 Id(9610Summary 选用) --- 可重复执行。 - -SET @db := DATABASE(); - -SET @exists := ( - SELECT COUNT(1) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_order' - AND COLUMN_NAME = 'ExportQualificationId' -); - -SET @sql := IF( - @exists = 0, - 'ALTER TABLE `t_order` - ADD COLUMN `ExportQualificationId` INT NULL COMMENT ''出口资质备案 Id(t_customer_export_qualification.Id)'' AFTER `ExportCustomsMode`, - ADD KEY `idx_order_export_qualification` (`ExportQualificationId`)', - 'SELECT ''t_order.ExportQualificationId already exists'' AS info' -); - -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_pickup_direction_and_address_type.sql b/sql/migrate_pickup_direction_and_address_type.sql deleted file mode 100644 index 8ebfac4..0000000 --- a/sql/migrate_pickup_direction_and_address_type.sql +++ /dev/null @@ -1,16 +0,0 @@ --- 揽收方向 + 客户地址类型扩展(国内揽收) --- 正向 forward:客户揽收地址 → 仓库 --- 逆向 reverse:仓库 → 客户揽收地址 - -ALTER TABLE `t_pickup_order` - ADD COLUMN `Direction` VARCHAR(16) NOT NULL DEFAULT 'forward' - COMMENT 'forward正向(客户→仓)/reverse逆向(仓→客户)' - AFTER `SenderAddressId`; - -ALTER TABLE `t_pickup_order` - ADD KEY `idx_pickup_direction_status` (`Direction`, `Status`, `CreateTime`); - --- 地址类型注释更新(列已存在,仅说明) -ALTER TABLE `t_customer_sender_address` - MODIFY COLUMN `AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender' - COMMENT 'sender国内发件/return国内退件/pickup国内揽收/receiver海外收件'; diff --git a/sql/migrate_pickup_fee_push.sql b/sql/migrate_pickup_fee_push.sql deleted file mode 100644 index 539f2a0..0000000 --- a/sql/migrate_pickup_fee_push.sql +++ /dev/null @@ -1,11 +0,0 @@ --- 揽收单:承运推送件重尺 + 系统卖价/买价费用快照 -ALTER TABLE `t_pickup_order` - ADD COLUMN `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重' AFTER `ChargeableWeight`, - ADD COLUMN `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON' AFTER `VolumetricWeight`, - ADD COLUMN `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)' AFTER `PackageSizesJson`, - ADD COLUMN `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)' AFTER `CarrierFreightWeight`, - ADD COLUMN `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)' AFTER `CarrierQuotedFreightAmount`, - ADD COLUMN `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文' AFTER `CarrierQuotedReceivableAmount`, - ADD COLUMN `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON' AFTER `CarrierQuoteJson`, - ADD COLUMN `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON' AFTER `ReceivableFeeJson`, - ADD COLUMN `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间' AFTER `PayableFeeJson`; diff --git a/sql/migrate_referral_invite_reward.sql b/sql/migrate_referral_invite_reward.sql new file mode 100644 index 0000000..3d23f2c --- /dev/null +++ b/sql/migrate_referral_invite_reward.sql @@ -0,0 +1,97 @@ +-- 邀请奖励:按实付基础运费百分比、T+N 工作日入运费余额、退款标记(可重复执行) +SET @db := DATABASE(); + +-- ---------- t_order.IsRefunded ---------- +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order' + AND COLUMN_NAME = 'IsRefunded' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_order` + ADD COLUMN `IsRefunded` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否已退款(分成计次排除)'' AFTER `IsPaid`', + 'SELECT ''t_order.IsRefunded already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ---------- t_referral_commission_rule 新字段 ---------- +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_referral_commission_rule' + AND COLUMN_NAME = 'MinInviteLevelCode' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_referral_commission_rule` + ADD COLUMN `MinInviteLevelCode` VARCHAR(16) NOT NULL DEFAULT ''V1'' COMMENT ''邀请资格最低等级'' AFTER `MonthlyCap`, + ADD COLUMN `MinPaidBaseFreight` DECIMAL(18,2) NOT NULL DEFAULT 50.00 COMMENT ''计奖最低实付基础运费'' AFTER `MinInviteLevelCode`, + ADD COLUMN `FirstOrderRate` DECIMAL(8,4) NOT NULL DEFAULT 0.0500 COMMENT ''首单奖励比例'' AFTER `MinPaidBaseFreight`, + ADD COLUMN `FirstOrderCap` DECIMAL(18,2) NOT NULL DEFAULT 50.00 COMMENT ''首单奖励封顶'' AFTER `FirstOrderRate`, + ADD COLUMN `FollowOrderRate` DECIMAL(8,4) NOT NULL DEFAULT 0.0300 COMMENT ''后续单奖励比例'' AFTER `FirstOrderCap`, + ADD COLUMN `FollowOrderCap` DECIMAL(18,2) NOT NULL DEFAULT 30.00 COMMENT ''后续单奖励封顶'' AFTER `FollowOrderRate`, + ADD COLUMN `FollowWindowDays` INT NOT NULL DEFAULT 90 COMMENT ''后续单注册后天数窗口'' AFTER `FollowOrderCap`, + ADD COLUMN `MaxRewardOrders` INT NOT NULL DEFAULT 6 COMMENT ''最多计奖订单数'' AFTER `FollowWindowDays`, + ADD COLUMN `SettleBusinessDays` INT NOT NULL DEFAULT 3 COMMENT ''派送成功后结算工作日'' AFTER `MaxRewardOrders`', + 'SELECT ''t_referral_commission_rule reward columns already exist'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `t_referral_commission_rule` +SET + `CommissionMode` = 'percent_of_paid_base_freight', + `SettleTriggerOrderStatus` = 'deliverySuccess', + `SettleBusinessDays` = CASE WHEN `SettleBusinessDays` > 0 THEN `SettleBusinessDays` ELSE 3 END, + `FreezeDays` = CASE WHEN `SettleBusinessDays` > 0 THEN `SettleBusinessDays` ELSE 3 END, + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0; + +-- ---------- t_referral_commission_detail ---------- +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_referral_commission_detail' + AND COLUMN_NAME = 'SettleDueTime' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_referral_commission_detail` + ADD COLUMN `SettleDueTime` DATETIME NULL COMMENT ''计划结算时间'' AFTER `EligibleTime`', + 'SELECT ''SettleDueTime already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_referral_commission_detail' + AND COLUMN_NAME = 'RewardOrderIndex' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_referral_commission_detail` + ADD COLUMN `RewardOrderIndex` INT NOT NULL DEFAULT 0 COMMENT ''被邀请人第几单'' AFTER `SettleDueTime`', + 'SELECT ''RewardOrderIndex already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `t_coupon_template` +SET + `Remark` = '被邀请人注册即发 5 元券', + `UpdateTime` = NOW() +WHERE `IsDeleted` = 0 + AND `TemplateCode` = 'REFERRAL_INVITEE'; diff --git a/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs b/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs index e60808d..4fb56f0 100644 --- a/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs +++ b/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs @@ -22,6 +22,24 @@ public class ReferralCommissionRuleOutput public decimal? MonthlyCap { get; set; } + public string MinInviteLevelCode { get; set; } = "V1"; + + public decimal MinPaidBaseFreight { get; set; } + + public decimal FirstOrderRate { get; set; } + + public decimal FirstOrderCap { get; set; } + + public decimal FollowOrderRate { get; set; } + + public decimal FollowOrderCap { get; set; } + + public int FollowWindowDays { get; set; } + + public int MaxRewardOrders { get; set; } + + public int SettleBusinessDays { get; set; } + public EnableStatus Status { get; set; } public string StatusName { get; set; } = string.Empty; @@ -50,6 +68,24 @@ public class ReferralCommissionRuleSaveInput public decimal? MonthlyCap { get; set; } + public string MinInviteLevelCode { get; set; } = "V1"; + + public decimal MinPaidBaseFreight { get; set; } = 50M; + + public decimal FirstOrderRate { get; set; } = 0.05M; + + public decimal FirstOrderCap { get; set; } = 50M; + + public decimal FollowOrderRate { get; set; } = 0.03M; + + public decimal FollowOrderCap { get; set; } = 30M; + + public int FollowWindowDays { get; set; } = 90; + + public int MaxRewardOrders { get; set; } = 6; + + public int SettleBusinessDays { get; set; } = 3; + public EnableStatus Status { get; set; } = EnableStatus.Enabled; } @@ -92,6 +128,10 @@ public class CommissionDetailOutput public decimal CommissionAmount { get; set; } + public int RewardOrderIndex { get; set; } + + public DateTime? SettleDueTime { get; set; } + public CommissionStatus Status { get; set; } public string StatusName { get; set; } = string.Empty; diff --git a/src/Oms.Application/Dtos/Marketing/CouponDtos.cs b/src/Oms.Application/Dtos/Marketing/CouponDtos.cs index 42d15c3..826c143 100644 --- a/src/Oms.Application/Dtos/Marketing/CouponDtos.cs +++ b/src/Oms.Application/Dtos/Marketing/CouponDtos.cs @@ -387,6 +387,10 @@ public class CouponInstanceOutput public DateTime? LockExpireTime { get; set; } + public string? TemplateCode { get; set; } + + public string? UnlockHint { get; set; } + } @@ -415,6 +419,10 @@ public class CouponAvailableInput public decimal? AmountAfterLevel { get; set; } + public decimal? BaseFreightAmount { get; set; } + + public string? DestinationCountry { get; set; } + } diff --git a/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs b/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs index 4ee00a8..ba4651b 100644 --- a/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs +++ b/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs @@ -4,6 +4,8 @@ public class MarketingPriceBreakdown { public decimal ReceivableSubtotal { get; set; } + public decimal BaseFreightAmount { get; set; } + public decimal LevelDiscountAmount { get; set; } public decimal AmountAfterLevel { get; set; } @@ -12,6 +14,8 @@ public class MarketingPriceBreakdown public decimal PayableAmount { get; set; } + public decimal PaidBaseFreightAmount { get; set; } + public int? MemberLevelId { get; set; } public string? MemberLevelName { get; set; } diff --git a/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs b/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs index 773eed9..84ba9ff 100644 --- a/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs +++ b/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs @@ -17,6 +17,9 @@ public class MemberLevelOutput public decimal ExtraDiscountRate { get; set; } + /// 是否允许降级。V4 钻石、V5 皇冠为 true,且只能逐级降。 + public bool CanDowngrade { get; set; } + public int Sort { get; set; } public string? IconUrl { get; set; } diff --git a/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs b/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs index f2f35bd..6aa2cc7 100644 --- a/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs +++ b/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs @@ -20,6 +20,8 @@ public class MemberLevelSaveInput public decimal ExtraDiscountRate { get; set; } + public bool CanDowngrade { get; set; } + public int Sort { get; set; } public string? IconUrl { get; set; } diff --git a/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs b/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs index 3c8a01e..b2b1cf3 100644 --- a/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs +++ b/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs @@ -1,25 +1,27 @@ -namespace Oms.Application.Dtos.Marketing; - -public class MemberOverviewOutput -{ - public int? MemberLevelId { get; set; } - - public string? MemberLevelName { get; set; } - - public string? PriceTierKey { get; set; } - - public int TotalPoints { get; set; } - - public int AvailablePoints { get; set; } - - public int? NextLevelId { get; set; } - - public string? NextLevelName { get; set; } - - public int? PointsToNextLevel { get; set; } - - public decimal ExtraDiscountRate { get; set; } - - public string? BenefitDesc { get; set; } -} +namespace Oms.Application.Dtos.Marketing; + +public class MemberOverviewOutput +{ + public int? MemberLevelId { get; set; } + + public string? MemberLevelName { get; set; } + + public string? PriceTierKey { get; set; } + + public int TotalPoints { get; set; } + + public int RollingPoints { get; set; } + + public int AvailablePoints { get; set; } + + public int? NextLevelId { get; set; } + + public string? NextLevelName { get; set; } + + public int? PointsToNextLevel { get; set; } + + public decimal ExtraDiscountRate { get; set; } + + public string? BenefitDesc { get; set; } +} \ No newline at end of file diff --git a/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs b/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs index 7c3a326..f8cc66e 100644 --- a/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs +++ b/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs @@ -1,123 +1,137 @@ -using System.ComponentModel.DataAnnotations; - -namespace Oms.Application.Dtos.Marketing; - -public class ReferralMyInviteOutput -{ - public string InviteCode { get; set; } = string.Empty; - - public byte Status { get; set; } - - public string StatusName { get; set; } = string.Empty; - - public int RefereeCount { get; set; } -} - -public class ReferralRefereeListInput -{ - public int Page { get; set; } - - public int PageSize { get; set; } -} - -public class ReferralRefereeOutput -{ - public int CustomerId { get; set; } - - public string CustomerCode { get; set; } = string.Empty; - - public string? CustomerNameMasked { get; set; } - - public string? PhoneMasked { get; set; } - - public DateTime CreateTime { get; set; } - - public bool HasPaidOrder { get; set; } - - public bool HasDeliverySuccessOrder { get; set; } -} - -public class ReferralValidateCodeOutput -{ - public bool Valid { get; set; } - - public string? InviterCustomerCode { get; set; } - - public string? InviterNameMasked { get; set; } - - public string? Message { get; set; } -} - -public class ReferralTreeInput -{ - public int? CustomerId { get; set; } - - public string? CustomerCode { get; set; } - - public int Page { get; set; } - - public int PageSize { get; set; } -} - -public class ReferralReferrerListInput -{ - public string? Keyword { get; set; } - - public int Page { get; set; } - - public int PageSize { get; set; } -} - -public class ReferralReferrerOutput -{ - public int CustomerId { get; set; } - - public string CustomerCode { get; set; } = string.Empty; - - public string CustomerName { get; set; } = string.Empty; - - public string? Phone { get; set; } - - public int RefereeCount { get; set; } - - public DateTime? LatestBindTime { get; set; } -} - -public class ReferralTreeNodeOutput -{ - public int CustomerId { get; set; } - - public string CustomerCode { get; set; } = string.Empty; - - public string CustomerName { get; set; } = string.Empty; - - public string? Phone { get; set; } - - public int? ReferrerCustomerId { get; set; } - - public string? ReferrerCustomerCode { get; set; } - - public DateTime CreateTime { get; set; } - - public DateTime? ReferralBindTime { get; set; } - - public string? ReferralChannel { get; set; } -} - -public class ReferralRebindInput -{ - public int? CustomerId { get; set; } - - public string? CustomerCode { get; set; } - - public int? NewReferrerCustomerId { get; set; } - - public string? NewReferrerCustomerCode { get; set; } - - public string? NewInviteCode { get; set; } - - [Required] - [StringLength(512, MinimumLength = 20)] - public string Reason { get; set; } = string.Empty; -} - \ No newline at end of file +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Marketing; + +public class ReferralMyInviteOutput +{ + public string InviteCode { get; set; } = string.Empty; + + public byte Status { get; set; } + + public string StatusName { get; set; } = string.Empty; + + public int RefereeCount { get; set; } + + public bool CanInvite { get; set; } + + public string MinInviteLevelCode { get; set; } = "V1"; + + public decimal PendingReward { get; set; } + + public decimal SettledReward { get; set; } +} + +public class ReferralRefereeListInput +{ + public int Page { get; set; } + + public int PageSize { get; set; } +} + +public class ReferralRefereeOutput +{ + public int CustomerId { get; set; } + + public string CustomerCode { get; set; } = string.Empty; + + public string? CustomerNameMasked { get; set; } + + public string? PhoneMasked { get; set; } + + public DateTime CreateTime { get; set; } + + public bool HasPaidOrder { get; set; } + + public bool HasDeliverySuccessOrder { get; set; } + + public int RewardOrderCount { get; set; } + + public decimal PendingReward { get; set; } + + public decimal SettledReward { get; set; } +} + +public class ReferralValidateCodeOutput +{ + public bool Valid { get; set; } + + public string? InviterCustomerCode { get; set; } + + public string? InviterNameMasked { get; set; } + + public string? Message { get; set; } +} + +public class ReferralTreeInput +{ + public int? CustomerId { get; set; } + + public string? CustomerCode { get; set; } + + public int Page { get; set; } + + public int PageSize { get; set; } +} + +public class ReferralReferrerListInput +{ + public string? Keyword { get; set; } + + public int Page { get; set; } + + public int PageSize { get; set; } +} + +public class ReferralReferrerOutput +{ + public int CustomerId { get; set; } + + public string CustomerCode { get; set; } = string.Empty; + + public string CustomerName { get; set; } = string.Empty; + + public string? Phone { get; set; } + + public int RefereeCount { get; set; } + + public DateTime? LatestBindTime { get; set; } +} + +public class ReferralTreeNodeOutput +{ + public int CustomerId { get; set; } + + public string CustomerCode { get; set; } = string.Empty; + + public string CustomerName { get; set; } = string.Empty; + + public string? Phone { get; set; } + + public int? ReferrerCustomerId { get; set; } + + public string? ReferrerCustomerCode { get; set; } + + public DateTime CreateTime { get; set; } + + public DateTime? ReferralBindTime { get; set; } + + public string? ReferralChannel { get; set; } +} + +public class ReferralRebindInput +{ + public int? CustomerId { get; set; } + + public string? CustomerCode { get; set; } + + public int? NewReferrerCustomerId { get; set; } + + public string? NewReferrerCustomerCode { get; set; } + + public string? NewInviteCode { get; set; } + + [Required] + [StringLength(512, MinimumLength = 20)] + public string Reason { get; set; } = string.Empty; +} + diff --git a/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs b/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs new file mode 100644 index 0000000..ac425f8 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +public class OrderRefundInput +{ + [Required] + public int Id { get; set; } + + /// + /// true=全额退款(可退券);false=部分退款(券不退回)。 + /// + public bool IsFullRefund { get; set; } = true; +} diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index 8e6bed7..dd14bec 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -242,7 +242,9 @@ public class CustomerService : ApplicationService entity, input.InviteCode, ReferralCodeRepository, - CustomerRepository); + CustomerRepository, + MemberLevelRepository, + LazyServiceProvider.GetRequiredService()); await StaffAdvisorBinder.BindAdvisorOnCreateAsync( entity, input.AdvisorCode, @@ -251,6 +253,10 @@ public class CustomerService : ApplicationService try { await CouponManager.GrantNewcomerCouponsAsync(entity.Id); + if (entity.ReferrerCustomerId.HasValue) + { + await CouponManager.GrantInviteeCouponAsync(entity.Id); + } } catch { diff --git a/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs b/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs index 75f867e..fe8ffc3 100644 --- a/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs @@ -246,6 +246,8 @@ public class MarketingCommissionAdminService : ApplicationService Status = entity.Status, StatusName = MarketingEnumNames.Of(entity.Status), EligibleTime = entity.EligibleTime, + SettleDueTime = entity.SettleDueTime, + RewardOrderIndex = entity.RewardOrderIndex, SettleTime = entity.SettleTime, CancelReason = entity.CancelReason, CreateTime = entity.CreateTime diff --git a/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs b/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs index dc79370..6f43d77 100644 --- a/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs @@ -47,15 +47,26 @@ public class MarketingCommissionRuleService : ApplicationService entity.RuleName = input.RuleName.Trim(); entity.CommissionMode = string.IsNullOrWhiteSpace(input.CommissionMode) - ? "per_amount" + ? "percent_of_paid_base_freight" : input.CommissionMode.Trim(); - entity.BaseAmount = input.BaseAmount; - entity.CommissionAmount = input.CommissionAmount; - entity.FreezeDays = input.FreezeDays; + entity.BaseAmount = input.MinPaidBaseFreight; + entity.CommissionAmount = 0; entity.SettleTriggerOrderStatus = string.IsNullOrWhiteSpace(input.SettleTriggerOrderStatus) ? "deliverySuccess" : input.SettleTriggerOrderStatus.Trim(); entity.MonthlyCap = input.MonthlyCap; + entity.MinInviteLevelCode = string.IsNullOrWhiteSpace(input.MinInviteLevelCode) ? "V1" : input.MinInviteLevelCode.Trim(); + entity.MinPaidBaseFreight = input.MinPaidBaseFreight; + entity.FirstOrderRate = input.FirstOrderRate; + entity.FirstOrderCap = input.FirstOrderCap; + entity.FollowOrderRate = input.FollowOrderRate; + entity.FollowOrderCap = input.FollowOrderCap; + entity.FollowWindowDays = input.FollowWindowDays; + entity.MaxRewardOrders = input.MaxRewardOrders; + entity.SettleBusinessDays = input.SettleBusinessDays >= 0 + ? input.SettleBusinessDays + : Math.Max(input.FreezeDays, 0); + entity.FreezeDays = entity.SettleBusinessDays; entity.Status = input.Status; if (input.Id is > 0) @@ -75,19 +86,20 @@ public class MarketingCommissionRuleService : ApplicationService throw new BusinessException("Rule name is required"); } - if (input.BaseAmount <= 0) + if (input.MinPaidBaseFreight < 0 + || input.FirstOrderRate is < 0 or > 1 + || input.FirstOrderCap < 0 + || input.FollowOrderRate is < 0 or > 1 + || input.FollowOrderCap < 0 + || input.FollowWindowDays <= 0 + || input.MaxRewardOrders <= 0) { - throw new BusinessException("Base amount must be positive"); + throw new BusinessException("Invite reward parameters are invalid"); } - if (input.CommissionAmount <= 0) + if (input.SettleBusinessDays < 0 && input.FreezeDays < 0) { - throw new BusinessException("Commission amount must be positive"); - } - - if (input.FreezeDays < 0) - { - throw new BusinessException("Freeze days cannot be negative"); + throw new BusinessException("Settle business days cannot be negative"); } } @@ -101,6 +113,15 @@ public class MarketingCommissionRuleService : ApplicationService FreezeDays = rule.FreezeDays, SettleTriggerOrderStatus = rule.SettleTriggerOrderStatus, MonthlyCap = rule.MonthlyCap, + MinInviteLevelCode = rule.MinInviteLevelCode, + MinPaidBaseFreight = rule.MinPaidBaseFreight, + FirstOrderRate = rule.FirstOrderRate, + FirstOrderCap = rule.FirstOrderCap, + FollowOrderRate = rule.FollowOrderRate, + FollowOrderCap = rule.FollowOrderCap, + FollowWindowDays = rule.FollowWindowDays, + MaxRewardOrders = rule.MaxRewardOrders, + SettleBusinessDays = rule.SettleBusinessDays > 0 ? rule.SettleBusinessDays : rule.FreezeDays, Status = rule.Status, StatusName = MarketingEnumNames.Of(rule.Status), CreateTime = rule.CreateTime, diff --git a/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs b/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs index 9899c3d..073e645 100644 --- a/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs @@ -224,6 +224,10 @@ public class MarketingCouponGrantService : ApplicationService ValidEndTime = entity.ValidEndTime, UseTime = entity.UseTime, OrderId = entity.OrderId, - LockExpireTime = entity.LockExpireTime + LockExpireTime = entity.LockExpireTime, + TemplateCode = template?.TemplateCode, + UnlockHint = entity.Status == CouponInstanceStatus.PendingUnlock + ? "完成首单派送即可解锁" + : null }; } diff --git a/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs b/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs index d45d478..5230798 100644 --- a/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs @@ -165,6 +165,12 @@ public class MarketingCouponTemplateService : ApplicationService throw new BusinessException("Template code already exists"); } + if (input.ValidType == CouponValidType.DaysAfterUnlock && + (!input.ValidDaysAfterReceive.HasValue || input.ValidDaysAfterReceive.Value <= 0)) + { + throw new BusinessException("Unlock validity days is required"); + } + ValidateCouponTypeFields(input); } diff --git a/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs b/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs index ee89690..455dd90 100644 --- a/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs @@ -132,9 +132,9 @@ public class MarketingMemberLevelService : ApplicationService throw new BusinessException("等级名称不能为空"); } - if (input.ExtraDiscountRate < 0 || input.ExtraDiscountRate >= 1) + if (input.ExtraDiscountRate < 0 || input.ExtraDiscountRate > MarketingBillingCalculator.MaxFreightOffRate) { - throw new BusinessException("额外折扣率须在 0~1 之间(不含 1)"); + throw new BusinessException("基础运费折扣率须在 0~0.05 之间(0.05 即 95 折封顶)"); } if (await MemberLevelRepository.ExistsLevelCodeAsync(levelCode, excludeId)) @@ -149,6 +149,7 @@ public class MarketingMemberLevelService : ApplicationService entity.LevelName = input.LevelName.Trim(); entity.MinPoints = input.MinPoints; entity.ExtraDiscountRate = input.ExtraDiscountRate; + entity.CanDowngrade = input.CanDowngrade; entity.Sort = input.Sort; entity.IconUrl = string.IsNullOrWhiteSpace(input.IconUrl) ? null : input.IconUrl.Trim(); entity.BenefitDesc = string.IsNullOrWhiteSpace(input.BenefitDesc) ? null : input.BenefitDesc.Trim(); @@ -166,6 +167,7 @@ public class MarketingMemberLevelService : ApplicationService LevelName = entity.LevelName, MinPoints = entity.MinPoints, ExtraDiscountRate = entity.ExtraDiscountRate, + CanDowngrade = entity.CanDowngrade, Sort = entity.Sort, IconUrl = entity.IconUrl, BenefitDesc = entity.BenefitDesc, diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs index 54e1901..3c3e011 100644 --- a/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs @@ -116,6 +116,8 @@ public class MarketingPortalCommissionService : ApplicationService Status = entity.Status, StatusName = MarketingEnumNames.Of(entity.Status), EligibleTime = entity.EligibleTime, + SettleDueTime = entity.SettleDueTime, + RewardOrderIndex = entity.RewardOrderIndex, SettleTime = entity.SettleTime, CancelReason = entity.CancelReason, CreateTime = entity.CreateTime diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs index 93f0c15..cdd8ab5 100644 --- a/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs @@ -84,7 +84,8 @@ public class MarketingPortalCouponService : ApplicationService var mainCustomer = await MarketingPortalSessionHelper.ResolveMainCustomerAsync( CustomerRepository, CurrentUser); - return await CouponManager.BuildAvailableCouponsAsync(mainCustomer.Id, input.AmountAfterLevel); + return await CouponManager.BuildAvailableCouponsAsync( + mainCustomer.Id, input.BaseFreightAmount ?? input.AmountAfterLevel, input.DestinationCountry); } /// diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs index 4c382be..15726fa 100644 --- a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs @@ -38,6 +38,7 @@ public class MarketingPortalMemberService : ApplicationService CustomerRepository, CurrentUser); var account = await PointsManager.GetOrCreateAccountAsync(mainCustomer.Id); + var rollingPoints = await PointsManager.GetRollingPointsAsync(mainCustomer.Id); var levels = await MemberLevelRepository.GetAllEnabledOrderedAsync(); MemberLevel? currentLevel = null; @@ -48,7 +49,7 @@ public class MarketingPortalMemberService : ApplicationService } var nextLevel = levels - .Where(x => x.MinPoints > account.TotalPoints) + .Where(x => x.MinPoints > rollingPoints) .OrderBy(x => x.MinPoints) .ThenBy(x => x.Sort) .FirstOrDefault(); @@ -59,10 +60,11 @@ public class MarketingPortalMemberService : ApplicationService MemberLevelName = currentLevel?.LevelName, PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey), TotalPoints = account.TotalPoints, + RollingPoints = rollingPoints, AvailablePoints = account.AvailablePoints, NextLevelId = nextLevel?.Id, NextLevelName = nextLevel?.LevelName, - PointsToNextLevel = nextLevel == null ? null : nextLevel.MinPoints - account.TotalPoints, + PointsToNextLevel = nextLevel == null ? null : Math.Max(0, nextLevel.MinPoints - rollingPoints), ExtraDiscountRate = currentLevel?.ExtraDiscountRate ?? 0M, BenefitDesc = currentLevel?.BenefitDesc }; diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs index 16cb88d..2fe0eca 100644 --- a/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs @@ -28,6 +28,15 @@ public class MarketingPortalReferralService : ApplicationService private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService(); + private IMemberLevelRepository MemberLevelRepository => + LazyServiceProvider.GetRequiredService(); + + private IReferralCommissionRuleRepository CommissionRuleRepository => + LazyServiceProvider.GetRequiredService(); + + private IReferralCommissionDetailRepository CommissionDetailRepository => + LazyServiceProvider.GetRequiredService(); + private MarketingReferralCodeProvisioner ReferralCodeProvisioner => LazyServiceProvider.GetRequiredService(); @@ -48,13 +57,22 @@ public class MarketingPortalReferralService : ApplicationService var referralCode = await ReferralCodeProvisioner.GetOrCreateAsync(mainCustomer.Id); var refereeCount = await CustomerRepository.CountAsync( x => x.ReferrerCustomerId == mainCustomer.Id && x.ParentCustomerId == null); + var (canInvite, minLevel) = await ResolveCanInviteAsync(mainCustomer); + var pendingReward = await CommissionDetailRepository.SumByReferrerAsync( + mainCustomer.Id, CommissionStatus.Pending); + var settledReward = await CommissionDetailRepository.SumByReferrerAsync( + mainCustomer.Id, CommissionStatus.Settled); return new ReferralMyInviteOutput { InviteCode = referralCode.InviteCode, Status = (byte)referralCode.Status, StatusName = MarketingEnumNames.Of(referralCode.Status), - RefereeCount = refereeCount + RefereeCount = refereeCount, + CanInvite = canInvite, + MinInviteLevelCode = minLevel, + PendingReward = pendingReward, + SettledReward = settledReward }; } @@ -82,6 +100,14 @@ public class MarketingPortalReferralService : ApplicationService var total = await CustomerRepository.CountAsync( x => x.ReferrerCustomerId == mainCustomer.Id && x.ParentCustomerId == null); + var refereeIds = entities.Select(x => x.Id).ToArray(); + var details = await CommissionDetailRepository.ListByReferrerAndRefereesAsync( + mainCustomer.Id, refereeIds); + var detailsByReferee = details + .Where(x => x.Status != CommissionStatus.Cancelled) + .GroupBy(x => x.RefereeCustomerId) + .ToDictionary(g => g.Key, g => g.ToList()); + var items = new List(); foreach (var entity in entities) { @@ -90,6 +116,8 @@ public class MarketingPortalReferralService : ApplicationService var hasDeliverySuccess = await OrderRepository.AnyAsync( x => x.CustomerId == entity.Id && x.OrderStatus == OrderStatusCodes.DeliverySuccess); + detailsByReferee.TryGetValue(entity.Id, out var refereeDetails); + refereeDetails ??= []; items.Add(new ReferralRefereeOutput { @@ -99,7 +127,14 @@ public class MarketingPortalReferralService : ApplicationService PhoneMasked = MarketingPortalSessionHelper.MaskPhone(entity.Phone), CreateTime = entity.CreateTime, HasPaidOrder = hasPaidOrder, - HasDeliverySuccessOrder = hasDeliverySuccess + HasDeliverySuccessOrder = hasDeliverySuccess, + RewardOrderCount = refereeDetails.Count, + PendingReward = refereeDetails + .Where(x => x.Status == CommissionStatus.Pending) + .Sum(x => x.CommissionAmount), + SettledReward = refereeDetails + .Where(x => x.Status == CommissionStatus.Settled) + .Sum(x => x.CommissionAmount) }); } @@ -143,6 +178,16 @@ public class MarketingPortalReferralService : ApplicationService }; } + var (canInvite, _) = await ResolveCanInviteAsync(inviter); + if (!canInvite) + { + return new ReferralValidateCodeOutput + { + Valid = false, + Message = "邀请人暂无邀请资格" + }; + } + return new ReferralValidateCodeOutput { Valid = true, @@ -151,4 +196,18 @@ public class MarketingPortalReferralService : ApplicationService Message = "邀请码有效" }; } + + private async Task<(bool CanInvite, string MinLevel)> ResolveCanInviteAsync( + Domain.CustomersAggregate.Entities.Customer customer) + { + var rule = await CommissionRuleRepository.GetActiveRuleAsync(); + var minLevel = string.IsNullOrWhiteSpace(rule?.MinInviteLevelCode) ? "V1" : rule.MinInviteLevelCode; + Domain.MarketingAggregate.Entities.MemberLevel? level = null; + if (customer.MemberLevelId.HasValue) + { + level = await MemberLevelRepository.FirstOrDefaultAsync(x => x.Id == customer.MemberLevelId.Value); + } + + return (MarketingInviteRewardCalculator.CanInvite(level?.LevelCode, minLevel), minLevel); + } } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index f3c00a6..3423204 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2849,11 +2849,15 @@ public partial class OrderService private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId) { var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); - var receivableSubtotal = fees - .Where(x => x.Type == OrderFeeTypeCodes.Receivable) - .Sum(x => x.Amount); + var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees); + var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees); var marketingHelper = LazyServiceProvider.GetRequiredService(); - var breakdown = await marketingHelper.BuildBreakdownAsync(order.CustomerId, receivableSubtotal, couponInstanceId); + var breakdown = await marketingHelper.BuildBreakdownAsync( + order.CustomerId, + receivableSubtotal, + baseFreightAmount, + couponInstanceId, + order.ConsigneeCountry); marketingHelper.ApplySnapshotToOrder(order, breakdown, couponInstanceId); if (breakdown.CouponInstanceId.HasValue && order.CustomerId.HasValue) @@ -2883,14 +2887,15 @@ public partial class OrderService private async Task RefreshMarketingPayableAmountAsync(Order order) { var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); - var receivableSubtotal = fees - .Where(x => x.Type == OrderFeeTypeCodes.Receivable) - .Sum(x => x.Amount); + var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees); + var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees); var marketingHelper = LazyServiceProvider.GetRequiredService(); var breakdown = await marketingHelper.BuildBreakdownAsync( order.CustomerId, receivableSubtotal, - order.CouponInstanceId); + baseFreightAmount, + order.CouponInstanceId, + order.ConsigneeCountry); marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId); } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index 7220889..4000bae 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -326,15 +326,18 @@ public partial class OrderService foreach (var item in result.Items) { + var originalTotal = item.TotalCost; var breakdown = await marketingHelper.BuildBreakdownAsync( input.CustomerId, - item.TotalCost, - input.CouponInstanceId); + originalTotal, + item.Freight, + input.CouponInstanceId, + input.DestinationCountry); item.PriceBreakdown = breakdown; item.TotalCost = breakdown.PayableAmount; item.TotalCostNum = breakdown.PayableAmount; firstBreakdown ??= breakdown; - amountAfterLevel ??= breakdown.AmountAfterLevel; + amountAfterLevel ??= item.Freight; } result.PriceBreakdown = firstBreakdown; @@ -342,7 +345,8 @@ public partial class OrderService { result.AvailableCoupons = await marketingHelper.BuildAvailableCouponsAsync( input.CustomerId, - amountAfterLevel); + amountAfterLevel, + input.DestinationCountry); } } diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 4d7ab7f..9d33c6c 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -178,7 +178,11 @@ public partial class OrderService : ApplicationService var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer); var pointsManager = LazyServiceProvider.GetRequiredService(); - await pointsManager.EarnOnOrderPaidAsync(mainCustomer.Id, order.Id, payAmount); + var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight( + MarketingFeeHelper.SumReceivableBaseFreight(fees), + order.LevelDiscountAmount, + order.CouponDiscountAmount); + await pointsManager.EarnOnOrderPaidAsync(mainCustomer.Id, order.Id, paidBaseFreight, order.ServiceNo); order.PayTime = DateTime.Now; order.IsPaid = 1; @@ -199,6 +203,47 @@ public partial class OrderService : ApplicationService var commissionManager = LazyServiceProvider.GetRequiredService(); await commissionManager.OnOrderDeliverySuccessAsync(order.Id); + + var couponManager = LazyServiceProvider.GetRequiredService(); + await couponManager.OnFirstDeliverySuccessAsync(order); + } + + /// + /// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。 + /// + [HttpPost("/order/refund")] + public async Task RefundAsync(OrderRefundInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + if (order.IsPaid == 0) + { + throw new BusinessException("订单未支付,无需退款"); + } + + var couponManager = LazyServiceProvider.GetRequiredService(); + await couponManager.RestoreOnOrderRefundAsync(order, input.IsFullRefund); + + order.IsRefunded = 1; + await UpdateOrderAsync(order); + + var commissionManager = LazyServiceProvider.GetRequiredService(); + await commissionManager.ClawbackOnOrderRefundAsync(order); + + if (input.IsFullRefund && order.CustomerId.HasValue) + { + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); + if (customer != null) + { + var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync( + CustomerRepository, customer); + var pointsManager = LazyServiceProvider.GetRequiredService(); + await pointsManager.ClawbackOnOrderAsync( + mainCustomer.Id, + order.Id, + order.ServiceNo, + "订单退款扣回积分"); + } + } } /// @@ -265,6 +310,23 @@ public partial class OrderService : ApplicationService } order.OrderStatus = OrderStatusCodes.Deleted; + if (order.IsPaid > 0 && order.CustomerId.HasValue) + { + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); + if (customer != null) + { + var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync( + CustomerRepository, + customer); + var pointsManager = LazyServiceProvider.GetRequiredService(); + await pointsManager.ClawbackOnOrderAsync( + mainCustomer.Id, + order.Id, + order.ServiceNo, + "订单删除扣回积分"); + } + } + await OrderRepository.UpdateAsync(order); await OrderRepository.DeleteAsync(order); } diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 3b781c8..c408031 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -999,9 +999,10 @@ internal static class ParcelBillingCalculator }; } - private static bool IsFreightCostItem(string costItem) => - costItem.Equals("freight", StringComparison.OrdinalIgnoreCase) - || costItem.Contains("运费", StringComparison.OrdinalIgnoreCase); + public static bool IsFreightCostItem(string costItem) => + !string.IsNullOrWhiteSpace(costItem) + && (costItem.Equals("freight", StringComparison.OrdinalIgnoreCase) + || costItem.Contains("运费", StringComparison.OrdinalIgnoreCase)); internal static string ResolveFeeDisplayName(string costItem, string? priceName) { diff --git a/src/Oms.Application/Shared/CustomerBalanceMutator.cs b/src/Oms.Application/Shared/CustomerBalanceMutator.cs index a2ded58..4a904ea 100644 --- a/src/Oms.Application/Shared/CustomerBalanceMutator.cs +++ b/src/Oms.Application/Shared/CustomerBalanceMutator.cs @@ -63,6 +63,58 @@ public class CustomerBalanceMutator : IScopedDependency cancellationToken); } + public Task CreditReferralRewardAsync( + Customer customer, + decimal amount, + int detailId, + string? orderNo, + string? remark, + CancellationToken cancellationToken = default) + { + if (amount <= 0) + { + throw new BusinessException("奖励金额必须大于 0"); + } + + return ApplyAsync( + customer, + amount, + CustomerAccountFlowBizTypes.ReferralCommission, + detailId, + orderNo, + remark, + cancellationToken); + } + + public Task DebitReferralClawbackAsync( + Customer customer, + decimal amount, + int detailId, + string? orderNo, + string? remark, + CancellationToken cancellationToken = default) + { + if (amount <= 0) + { + return Task.CompletedTask; + } + + var debit = Math.Min(amount, customer.Balance); + if (debit <= 0) + { + return Task.CompletedTask; + } + + return ApplyAsync( + customer, + -debit, + CustomerAccountFlowBizTypes.ReferralCommission, + detailId, + orderNo, + remark, + cancellationToken); + } + public Task DebitPickupPayAsync( Customer customer, decimal amount, diff --git a/src/Oms.Application/Shared/MarketingBillingCalculator.cs b/src/Oms.Application/Shared/MarketingBillingCalculator.cs index b2b21b7..8887465 100644 --- a/src/Oms.Application/Shared/MarketingBillingCalculator.cs +++ b/src/Oms.Application/Shared/MarketingBillingCalculator.cs @@ -3,10 +3,12 @@ using Oms.Application.Dtos.Marketing; namespace Oms.Application.Shared; /// -/// 营销叠加计费(§11)。 +/// 会员折扣仅作用于基础运费;与优惠券不叠加,取更低应付(同价保留会员折,避免占券)。 /// public static class MarketingBillingCalculator { + public const decimal MaxFreightOffRate = 0.05M; + public static MarketingPriceBreakdown Calculate( decimal receivableSubtotal, decimal extraDiscountRate, @@ -15,35 +17,75 @@ public static class MarketingBillingCalculator string? priceTierKey, CouponDiscountInput? coupon) { - var levelDiscount = Math.Round(receivableSubtotal * extraDiscountRate, 2, MidpointRounding.AwayFromZero); - var amountAfterLevel = receivableSubtotal - levelDiscount; - var couponDiscount = coupon == null - ? 0M - : CalculateCouponDiscount(coupon, amountAfterLevel); - var payable = Math.Max(0M, amountAfterLevel - couponDiscount); - - return new MarketingPriceBreakdown - { - ReceivableSubtotal = receivableSubtotal, - LevelDiscountAmount = levelDiscount, - AmountAfterLevel = amountAfterLevel, - CouponDiscountAmount = couponDiscount, - PayableAmount = payable, - MemberLevelId = memberLevelId, - MemberLevelName = memberLevelName, - PriceTierKey = priceTierKey, - CouponInstanceId = coupon?.CouponInstanceId - }; + return Calculate( + receivableSubtotal, + receivableSubtotal, + extraDiscountRate, + memberLevelId, + memberLevelName, + priceTierKey, + coupon); } - public static decimal CalculateCouponDiscount(CouponDiscountInput coupon, decimal amountAfterLevel) + public static MarketingPriceBreakdown Calculate( + decimal receivableSubtotal, + decimal baseFreightAmount, + decimal extraDiscountRate, + int? memberLevelId, + string? memberLevelName, + string? priceTierKey, + CouponDiscountInput? coupon) { - if (amountAfterLevel <= 0) + receivableSubtotal = Math.Max(0M, receivableSubtotal); + baseFreightAmount = Math.Max(0M, Math.Min(baseFreightAmount, receivableSubtotal)); + extraDiscountRate = Math.Clamp(extraDiscountRate, 0M, MaxFreightOffRate); + + var levelDiscount = Math.Round(baseFreightAmount * extraDiscountRate, 2, MidpointRounding.AwayFromZero); + var payableMember = Math.Max(0M, receivableSubtotal - levelDiscount); + + var couponDiscount = coupon == null + ? 0M + : CalculateCouponDiscount(coupon, baseFreightAmount); + var payableCoupon = Math.Max(0M, receivableSubtotal - couponDiscount); + + var useCoupon = coupon != null && couponDiscount > 0M && payableCoupon < payableMember; + if (useCoupon) + { + return Build( + receivableSubtotal, + baseFreightAmount, + 0M, + couponDiscount, + payableCoupon, + memberLevelId, + memberLevelName, + priceTierKey, + coupon!.CouponInstanceId); + } + + return Build( + receivableSubtotal, + baseFreightAmount, + levelDiscount, + 0M, + payableMember, + memberLevelId, + memberLevelName, + priceTierKey, + null); + } + + /// + /// 仅按基础运费判断门槛并抵扣,封顶不超过基础运费(不找零、不冲附加费)。 + /// + public static decimal CalculateCouponDiscount(CouponDiscountInput coupon, decimal baseFreightAmount) + { + if (baseFreightAmount <= 0) { return 0M; } - if (amountAfterLevel < coupon.MinOrderAmount) + if (baseFreightAmount < coupon.MinOrderAmount) { return 0M; } @@ -52,12 +94,12 @@ public static class MarketingBillingCalculator { MarketingCouponTypeCodes.FixedOff => coupon.FaceValue ?? 0M, MarketingCouponTypeCodes.DirectOff => coupon.FaceValue ?? 0M, - MarketingCouponTypeCodes.PercentOff => CalculatePercentOff(amountAfterLevel, coupon.DiscountRate, coupon.MaxDiscountAmount), + MarketingCouponTypeCodes.PercentOff => CalculatePercentOff(baseFreightAmount, coupon.DiscountRate, coupon.MaxDiscountAmount), MarketingCouponTypeCodes.ShippingOff => coupon.FaceValue ?? 0M, _ => 0M }; - return Math.Min(Math.Max(0M, discount), amountAfterLevel); + return Math.Min(Math.Max(0M, discount), baseFreightAmount); } private static decimal CalculatePercentOff(decimal baseAmount, decimal? discountRate, decimal? maxDiscount) @@ -86,6 +128,36 @@ public static class MarketingBillingCalculator var segments = Math.Floor(basePayableAmount / baseAmount); return segments * commissionAmount; } + + private static MarketingPriceBreakdown Build( + decimal receivableSubtotal, + decimal baseFreightAmount, + decimal levelDiscount, + decimal couponDiscount, + decimal payable, + int? memberLevelId, + string? memberLevelName, + string? priceTierKey, + int? couponInstanceId) + { + return new MarketingPriceBreakdown + { + ReceivableSubtotal = receivableSubtotal, + BaseFreightAmount = baseFreightAmount, + LevelDiscountAmount = levelDiscount, + AmountAfterLevel = Math.Max(0M, receivableSubtotal - levelDiscount), + CouponDiscountAmount = couponDiscount, + PayableAmount = payable, + PaidBaseFreightAmount = MarketingFeeHelper.ResolvePaidBaseFreight( + baseFreightAmount, + levelDiscount, + couponDiscount), + MemberLevelId = memberLevelId, + MemberLevelName = memberLevelName, + PriceTierKey = priceTierKey, + CouponInstanceId = couponInstanceId + }; + } } public static class MarketingCouponTypeCodes @@ -112,4 +184,6 @@ public class CouponDiscountInput public decimal? DiscountRate { get; set; } public decimal? MaxDiscountAmount { get; set; } + + public string? ApplyScopeJson { get; set; } } diff --git a/src/Oms.Application/Shared/MarketingCouponManager.cs b/src/Oms.Application/Shared/MarketingCouponManager.cs index 58ab925..3abf583 100644 --- a/src/Oms.Application/Shared/MarketingCouponManager.cs +++ b/src/Oms.Application/Shared/MarketingCouponManager.cs @@ -4,25 +4,34 @@ using Fake.Domain.Exceptions; using Fake.Security.Claims; using Fake.Users; using Oms.Application.Dtos.Marketing; +using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.MarketingAggregate.Consts; using Oms.Domain.MarketingAggregate.Entities; using Oms.Domain.MarketingAggregate.Enums; using Oms.Domain.MarketingAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; using System.Text.Json; namespace Oms.Application.Shared; /// -/// 优惠券发放、锁定、核销与释放。 +/// 优惠券发放、锁定、核销、解锁与释放。 /// public class MarketingCouponManager : IScopedDependency { private const int LockMinutes = 30; + private static readonly DateTime PendingValidEnd = new(2099, 12, 31, 23, 59, 59); private readonly ICouponTemplateRepository _templateRepository; private readonly ICouponInstanceRepository _instanceRepository; private readonly ICustomerRepository _customerRepository; + private readonly IOrderRepository _orderRepository; + private readonly INoticeRepository _noticeRepository; private readonly IMarketingAuditLogRepository _auditLogRepository; private readonly ICurrentUser _currentUser; @@ -30,12 +39,16 @@ public class MarketingCouponManager : IScopedDependency ICouponTemplateRepository templateRepository, ICouponInstanceRepository instanceRepository, ICustomerRepository customerRepository, + IOrderRepository orderRepository, + INoticeRepository noticeRepository, IMarketingAuditLogRepository auditLogRepository, ICurrentUser currentUser) { _templateRepository = templateRepository; _instanceRepository = instanceRepository; _customerRepository = customerRepository; + _orderRepository = orderRepository; + _noticeRepository = noticeRepository; _auditLogRepository = auditLogRepository; _currentUser = currentUser; } @@ -60,13 +73,16 @@ public class MarketingCouponManager : IScopedDependency throw new BusinessException("该客户已达领取上限"); } - var (validStart, validEnd) = ResolveValidRange(template); + var pendingUnlock = RequiresUnlock(template); + var (validStart, validEnd) = pendingUnlock + ? (DateTime.Now, PendingValidEnd) + : ResolveValidRange(template); var instance = new CouponInstance { TemplateId = templateId, CustomerId = mainCustomerId, CouponCode = await GenerateCouponCodeAsync(cancellationToken), - Status = CouponInstanceStatus.Available, + Status = pendingUnlock ? CouponInstanceStatus.PendingUnlock : CouponInstanceStatus.Available, ReceiveTime = DateTime.Now, ValidStartTime = validStart, ValidEndTime = validEnd @@ -82,7 +98,7 @@ public class MarketingCouponManager : IScopedDependency } /// - /// 注册成功后发放所有「新人券」模板(GrantChannel=newcomer 且已发布),有多少发多少;单张失败不阻断。 + /// 注册成功后发放所有已发布新人券。第二单券发成未解锁。 /// public async Task GrantNewcomerCouponsAsync(int mainCustomerId, CancellationToken cancellationToken = default) { @@ -109,13 +125,65 @@ public class MarketingCouponManager : IScopedDependency } catch (BusinessException) { - // 库存不足、已达上限等跳过 } } return granted; } + public async Task GrantInviteeCouponAsync(int mainCustomerId, CancellationToken cancellationToken = default) + { + if (mainCustomerId <= 0) + { + return; + } + + var templates = await _templateRepository.GetListAsync( + x => x.Status == CouponTemplateStatus.Active + && x.GrantChannel == MarketingGrantChannels.Referral + && x.TemplateCode == MarketingCouponTemplateCodes.ReferralInvitee); + foreach (var template in templates) + { + try + { + await GrantAsync(template.Id, mainCustomerId, "referral_invitee_register", cancellationToken); + } + catch (BusinessException) + { + } + } + } + + public async Task OnFirstDeliverySuccessAsync(Order order, CancellationToken cancellationToken = default) + { + if (order.CustomerId is not > 0) + { + return; + } + + var sessionCustomer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value, cancellationToken); + if (sessionCustomer == null) + { + return; + } + + var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync( + _customerRepository, sessionCustomer, cancellationToken); + var familyIds = await ResolveFamilyCustomerIdsAsync(mainCustomer.Id, cancellationToken); + var hadOtherDelivery = await _orderRepository.AnyAsync( + x => x.Id != order.Id + && x.CustomerId != null + && familyIds.Contains(x.CustomerId.Value) + && x.OrderStatus == OrderStatusCodes.DeliverySuccess, + cancellationToken); + if (hadOtherDelivery) + { + return; + } + + await UnlockPendingCouponsAsync(mainCustomer.Id, cancellationToken); + } + public async Task LockForOrderAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default) { var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken); @@ -137,6 +205,44 @@ public class MarketingCouponManager : IScopedDependency await _instanceRepository.UpdateAsync(instance, cancellationToken); } + public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default) + { + if (!order.CouponInstanceId.HasValue) + { + return; + } + + var instance = await _instanceRepository.FirstOrDefaultAsync( + x => x.Id == order.CouponInstanceId.Value, cancellationToken); + if (instance == null || instance.Status != CouponInstanceStatus.Used) + { + return; + } + + var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken); + var isFirstOrderCoupon = template != null + && string.Equals(template.TemplateCode, MarketingCouponTemplateCodes.NewcomerFirst, StringComparison.OrdinalIgnoreCase); + + if (isFirstOrderCoupon || !isFullRefund) + { + return; + } + + if (instance.ValidEndTime < DateTime.Now) + { + instance.Status = CouponInstanceStatus.Expired; + instance.LockExpireTime = null; + await _instanceRepository.UpdateAsync(instance, cancellationToken); + return; + } + + instance.Status = CouponInstanceStatus.Available; + instance.OrderId = null; + instance.UseTime = null; + instance.LockExpireTime = null; + await _instanceRepository.UpdateAsync(instance, cancellationToken); + } + public async Task ReleaseLockAsync(int couponInstanceId, CancellationToken cancellationToken = default) { var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken); @@ -163,9 +269,53 @@ public class MarketingCouponManager : IScopedDependency } } + public async Task ExpireOverdueAsync(int batchSize, CancellationToken cancellationToken = default) + { + var overdue = await _instanceRepository.GetExpiredAvailableAsync(DateTime.Now, batchSize, cancellationToken); + foreach (var instance in overdue) + { + instance.Status = CouponInstanceStatus.Expired; + instance.LockExpireTime = null; + await _instanceRepository.UpdateAsync(instance, cancellationToken); + } + } + + public async Task NotifyExpiringAsync(int batchSize, CancellationToken cancellationToken = default) + { + var now = DateTime.Now; + var expiring = await _instanceRepository.GetExpiringAvailableAsync( + now, now.AddDays(3), batchSize, cancellationToken); + if (expiring.Count == 0) + { + return; + } + + var templateIds = expiring.Select(x => x.TemplateId).Distinct().ToArray(); + var templates = await _templateRepository.GetListAsync(x => templateIds.Contains(x.Id)); + var templateMap = templates.ToDictionary(x => x.Id); + + foreach (var instance in expiring) + { + templateMap.TryGetValue(instance.TemplateId, out var template); + var name = template?.TemplateName ?? "优惠券"; + await _noticeRepository.InsertAsync(new Notice + { + NoticeType = "message", + Title = "优惠券即将到期", + Content = $"您的「{name}」将于 {instance.ValidEndTime:yyyy-MM-dd HH:mm} 过期,请尽快下单使用。", + IsPopup = 0, + ReceiverType = "customer", + ReceiverIds = JsonSerializer.Serialize(new[] { instance.CustomerId }) + }, cancellationToken); + instance.ExpiryNoticeSent = 1; + await _instanceRepository.UpdateAsync(instance, cancellationToken); + } + } + public async Task ResolveCouponDiscountInputAsync( int? couponInstanceId, int mainCustomerId, + string? destinationCountry = null, CancellationToken cancellationToken = default) { if (!couponInstanceId.HasValue) @@ -177,6 +327,10 @@ public class MarketingCouponManager : IScopedDependency EnsureUsable(instance); var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken) ?? throw new BusinessException("券模板不存在"); + if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry)) + { + throw new BusinessException("该优惠券不适用于当前线路"); + } return new CouponDiscountInput { @@ -185,7 +339,8 @@ public class MarketingCouponManager : IScopedDependency MinOrderAmount = template.MinOrderAmount, FaceValue = template.FaceValue, DiscountRate = template.DiscountRate, - MaxDiscountAmount = template.MaxDiscountAmount + MaxDiscountAmount = template.MaxDiscountAmount, + ApplyScopeJson = template.ApplyScopeJson }; } @@ -198,7 +353,8 @@ public class MarketingCouponManager : IScopedDependency public async Task> BuildAvailableCouponsAsync( int mainCustomerId, - decimal? amountAfterLevel, + decimal? baseFreightAmount, + string? destinationCountry = null, CancellationToken cancellationToken = default) { var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken); @@ -220,12 +376,17 @@ public class MarketingCouponManager : IScopedDependency string? unavailableReason = null; decimal? estimatedDiscount = null; - if (amountAfterLevel.HasValue) + if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry)) { - if (amountAfterLevel.Value < template.MinOrderAmount) + canUse = false; + unavailableReason = "不适用于当前线路"; + } + else if (baseFreightAmount.HasValue) + { + if (baseFreightAmount.Value < template.MinOrderAmount) { canUse = false; - unavailableReason = $"未满 {template.MinOrderAmount:0.##} 元门槛"; + unavailableReason = $"实付基础运费未满 {template.MinOrderAmount:0.##} 元"; } else { @@ -236,15 +397,16 @@ public class MarketingCouponManager : IScopedDependency MinOrderAmount = template.MinOrderAmount, FaceValue = template.FaceValue, DiscountRate = template.DiscountRate, - MaxDiscountAmount = template.MaxDiscountAmount + MaxDiscountAmount = template.MaxDiscountAmount, + ApplyScopeJson = template.ApplyScopeJson }; estimatedDiscount = MarketingBillingCalculator.CalculateCouponDiscount( discountInput, - amountAfterLevel.Value); + baseFreightAmount.Value); if (estimatedDiscount <= 0) { canUse = false; - unavailableReason = "当前金额不可用"; + unavailableReason = "当前基础运费不可用"; } } } @@ -267,6 +429,35 @@ public class MarketingCouponManager : IScopedDependency }).ToList(); } + private async Task UnlockPendingCouponsAsync(int mainCustomerId, CancellationToken cancellationToken) + { + var pending = await _instanceRepository.GetPendingUnlockByCustomerAsync(mainCustomerId, cancellationToken); + if (pending.Count == 0) + { + return; + } + + var templateIds = pending.Select(x => x.TemplateId).Distinct().ToArray(); + var templates = await _templateRepository.GetListAsync(x => templateIds.Contains(x.Id)); + var templateMap = templates.ToDictionary(x => x.Id); + var now = DateTime.Now; + foreach (var instance in pending) + { + templateMap.TryGetValue(instance.TemplateId, out var template); + var days = template?.ValidDaysAfterReceive ?? 30; + instance.Status = CouponInstanceStatus.Available; + instance.ValidStartTime = now; + instance.ValidEndTime = now.AddDays(days); + await _instanceRepository.UpdateAsync(instance, cancellationToken); + } + } + + private async Task ResolveFamilyCustomerIdsAsync(int mainCustomerId, CancellationToken cancellationToken) + { + var children = await _customerRepository.GetListAsync(x => x.ParentCustomerId == mainCustomerId); + return children.Select(x => x.Id).Append(mainCustomerId).ToArray(); + } + private async Task GetOwnedInstanceAsync(int id, int mainCustomerId, CancellationToken cancellationToken) { var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == id, cancellationToken) @@ -282,6 +473,11 @@ public class MarketingCouponManager : IScopedDependency private static void EnsureUsable(CouponInstance instance) { var now = DateTime.Now; + if (instance.Status == CouponInstanceStatus.PendingUnlock) + { + throw new BusinessException("优惠券未解锁,完成首单派送后可用"); + } + if (instance.Status == CouponInstanceStatus.Used) { throw new BusinessException("优惠券已使用"); @@ -305,9 +501,24 @@ public class MarketingCouponManager : IScopedDependency } } + private static bool RequiresUnlock(CouponTemplate template) + { + if (template.ValidType == CouponValidType.DaysAfterUnlock) + { + return true; + } + + return string.Equals( + template.TemplateCode, + MarketingCouponTemplateCodes.NewcomerSecond, + StringComparison.OrdinalIgnoreCase); + } + private static (DateTime Start, DateTime End) ResolveValidRange(CouponTemplate template) { - if (template.ValidType == CouponValidType.DaysAfterReceive && template.ValidDaysAfterReceive.HasValue) + if ((template.ValidType == CouponValidType.DaysAfterReceive + || template.ValidType == CouponValidType.DaysAfterUnlock) + && template.ValidDaysAfterReceive.HasValue) { var start = DateTime.Now; return (start, start.AddDays(template.ValidDaysAfterReceive.Value)); diff --git a/src/Oms.Application/Shared/MarketingCouponScope.cs b/src/Oms.Application/Shared/MarketingCouponScope.cs new file mode 100644 index 0000000..b26e4fe --- /dev/null +++ b/src/Oms.Application/Shared/MarketingCouponScope.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Oms.Application.Shared; + +public class CouponApplyScope +{ + [JsonPropertyName("countryCodes")] + public List? CountryCodes { get; set; } +} + +public static class MarketingCouponScope +{ + public static bool Matches(string? applyScopeJson, string? destinationCountry) + { + var codes = ParseCountryCodes(applyScopeJson); + if (codes.Count == 0) + { + return true; + } + + if (string.IsNullOrWhiteSpace(destinationCountry)) + { + return false; + } + + var dest = destinationCountry.Trim(); + return codes.Any(x => string.Equals(x, dest, StringComparison.OrdinalIgnoreCase)); + } + + public static IReadOnlyList ParseCountryCodes(string? applyScopeJson) + { + if (string.IsNullOrWhiteSpace(applyScopeJson)) + { + return []; + } + + try + { + var scope = JsonSerializer.Deserialize(applyScopeJson); + return (scope?.CountryCodes ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (JsonException) + { + return []; + } + } +} diff --git a/src/Oms.Application/Shared/MarketingCustomerHelper.cs b/src/Oms.Application/Shared/MarketingCustomerHelper.cs index 830a737..b856fbd 100644 --- a/src/Oms.Application/Shared/MarketingCustomerHelper.cs +++ b/src/Oms.Application/Shared/MarketingCustomerHelper.cs @@ -113,7 +113,9 @@ public static class MarketingCustomerHelper { var named = await memberLevelRepository.FirstOrDefaultAsync( x => x.Status == EnableStatus.Enabled - && (x.LevelName == DefaultMemberLevelName || x.LevelCode == DefaultMemberLevelName), + && (x.LevelCode == "V0" + || x.LevelCode == "normal" + || x.LevelName == DefaultMemberLevelName), cancellationToken); if (named != null) { diff --git a/src/Oms.Application/Shared/MarketingEnumNames.cs b/src/Oms.Application/Shared/MarketingEnumNames.cs index 70b884a..dbe8f72 100644 --- a/src/Oms.Application/Shared/MarketingEnumNames.cs +++ b/src/Oms.Application/Shared/MarketingEnumNames.cs @@ -23,6 +23,7 @@ public static class MarketingEnumNames public static string Of(CouponInstanceStatus status) => status switch { + CouponInstanceStatus.PendingUnlock => "未解锁", CouponInstanceStatus.Available => "可用", CouponInstanceStatus.Locked => "预占中", CouponInstanceStatus.Used => "已使用", @@ -88,6 +89,9 @@ public static class MarketingEnumNames MarketingGrantChannels.SelfReceive => "客户自领", MarketingGrantChannels.Portal => "门户领取", MarketingGrantChannels.Manual => "手动发放", + MarketingGrantChannels.Referral => "裂变券", + MarketingGrantChannels.Route => "线路定向券", + MarketingGrantChannels.Festival => "节日券", _ => grantChannel }; } diff --git a/src/Oms.Application/Shared/MarketingFeeHelper.cs b/src/Oms.Application/Shared/MarketingFeeHelper.cs new file mode 100644 index 0000000..52a8bf1 --- /dev/null +++ b/src/Oms.Application/Shared/MarketingFeeHelper.cs @@ -0,0 +1,33 @@ +using Oms.Application.Services.Orders; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Shared; + +/// +/// 会员积分/折扣用的基础运费口径:仅 freight / 名称含「运费」的应收。 +/// +public static class MarketingFeeHelper +{ + public static bool IsBaseFreightCostItem(string? costItem) => + !string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem); + + public static decimal SumReceivable(IEnumerable fees) => + fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable).Sum(x => x.Amount); + + public static decimal SumReceivableBaseFreight(IEnumerable fees) => + fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem)) + .Sum(x => x.Amount); + + /// + /// 会员折与券互斥,扣减最多只落一笔;优惠券抵扣不计入积分。 + /// + public static decimal ResolvePaidBaseFreight( + decimal baseFreightAmount, + decimal levelDiscountAmount, + decimal couponDiscountAmount) + { + var cut = Math.Max(0M, levelDiscountAmount) + Math.Max(0M, couponDiscountAmount); + return Math.Max(0M, baseFreightAmount - cut); + } +} diff --git a/src/Oms.Application/Shared/MarketingInviteRewardCalculator.cs b/src/Oms.Application/Shared/MarketingInviteRewardCalculator.cs new file mode 100644 index 0000000..fe04cb0 --- /dev/null +++ b/src/Oms.Application/Shared/MarketingInviteRewardCalculator.cs @@ -0,0 +1,141 @@ +namespace Oms.Application.Shared; + +public class InviteRewardRuleSnapshot +{ + public decimal MinPaidBaseFreight { get; set; } = 50M; + + public decimal FirstOrderRate { get; set; } = 0.05M; + + public decimal FirstOrderCap { get; set; } = 50M; + + public decimal FollowOrderRate { get; set; } = 0.03M; + + public decimal FollowOrderCap { get; set; } = 30M; + + public int FollowWindowDays { get; set; } = 90; + + public int MaxRewardOrders { get; set; } = 6; + + public int SettleBusinessDays { get; set; } = 3; + + public string MinInviteLevelCode { get; set; } = "V1"; +} + +public class InviteRewardDecision +{ + public bool Eligible { get; set; } + + public int OrderIndex { get; set; } + + public decimal Rate { get; set; } + + public decimal Cap { get; set; } + + public decimal Amount { get; set; } +} + +public static class MarketingInviteRewardCalculator +{ + public static InviteRewardDecision Resolve( + decimal paidBaseFreight, + int completedOrderIndex, + DateTime refereeRegisterTime, + DateTime completeTime, + InviteRewardRuleSnapshot rule) + { + var decision = new InviteRewardDecision { OrderIndex = completedOrderIndex }; + if (completedOrderIndex <= 0 || paidBaseFreight < rule.MinPaidBaseFreight) + { + return decision; + } + + if (completedOrderIndex == 1) + { + decision.Eligible = true; + decision.Rate = rule.FirstOrderRate; + decision.Cap = rule.FirstOrderCap; + decision.Amount = Cap(paidBaseFreight * rule.FirstOrderRate, rule.FirstOrderCap); + return decision; + } + + if (completedOrderIndex > rule.MaxRewardOrders) + { + return decision; + } + + var windowEnd = refereeRegisterTime.Date.AddDays(rule.FollowWindowDays); + if (completeTime.Date > windowEnd) + { + return decision; + } + + decision.Eligible = true; + decision.Rate = rule.FollowOrderRate; + decision.Cap = rule.FollowOrderCap; + decision.Amount = Cap(paidBaseFreight * rule.FollowOrderRate, rule.FollowOrderCap); + return decision; + } + + public static DateTime AddBusinessDays(DateTime start, int days) + { + if (days <= 0) + { + return start; + } + + var cursor = start; + var added = 0; + while (added < days) + { + cursor = cursor.AddDays(1); + if (cursor.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) + { + continue; + } + + added++; + } + + return cursor; + } + + public static int ParseLevelRank(string? levelCode) + { + if (string.IsNullOrWhiteSpace(levelCode)) + { + return 0; + } + + var code = levelCode.Trim(); + if (code.Length >= 2 && (code[0] == 'V' || code[0] == 'v') && int.TryParse(code[1..], out var n)) + { + return n; + } + + return code.ToLowerInvariant() switch + { + "normal" => 0, + "silver" => 2, + "gold" => 3, + "diamond" => 4, + _ => 0 + }; + } + + public static bool CanInvite(string? memberLevelCode, string? minInviteLevelCode) + { + return ParseLevelRank(memberLevelCode) >= ParseLevelRank( + string.IsNullOrWhiteSpace(minInviteLevelCode) ? "V1" : minInviteLevelCode); + } + + private static decimal Cap(decimal amount, decimal cap) + { + var value = Math.Round(Math.Max(0M, amount), 2, MidpointRounding.AwayFromZero); + if (cap > 0) + { + value = Math.Min(value, cap); + } + + return value; + } +} diff --git a/src/Oms.Application/Shared/MarketingOrderHelper.cs b/src/Oms.Application/Shared/MarketingOrderHelper.cs index ed46a75..f06a035 100644 --- a/src/Oms.Application/Shared/MarketingOrderHelper.cs +++ b/src/Oms.Application/Shared/MarketingOrderHelper.cs @@ -31,16 +31,50 @@ public class MarketingOrderHelper : IScopedDependency decimal receivableSubtotal, int? couponInstanceId, CancellationToken cancellationToken = default) + { + return await BuildBreakdownAsync( + customerId, + receivableSubtotal, + receivableSubtotal, + couponInstanceId, + cancellationToken); + } + + public async Task BuildBreakdownAsync( + int? customerId, + decimal receivableSubtotal, + decimal baseFreightAmount, + int? couponInstanceId, + CancellationToken cancellationToken = default) + { + return await BuildBreakdownAsync( + customerId, + receivableSubtotal, + baseFreightAmount, + couponInstanceId, + null, + cancellationToken); + } + + public async Task BuildBreakdownAsync( + int? customerId, + decimal receivableSubtotal, + decimal baseFreightAmount, + int? couponInstanceId, + string? destinationCountry, + CancellationToken cancellationToken = default) { if (!customerId.HasValue || customerId.Value <= 0) { - return MarketingBillingCalculator.Calculate(receivableSubtotal, 0M, null, null, null, null); + return MarketingBillingCalculator.Calculate( + receivableSubtotal, baseFreightAmount, 0M, null, null, null, null); } var sessionCustomer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == customerId.Value, cancellationToken); if (sessionCustomer == null) { - return MarketingBillingCalculator.Calculate(receivableSubtotal, 0M, null, null, null, null); + return MarketingBillingCalculator.Calculate( + receivableSubtotal, baseFreightAmount, 0M, null, null, null, null); } var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(_customerRepository, sessionCustomer, cancellationToken); @@ -52,10 +86,12 @@ public class MarketingOrderHelper : IScopedDependency } var priceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey); - var couponInput = await _couponManager.ResolveCouponDiscountInputAsync(couponInstanceId, mainCustomerId, cancellationToken); + var couponInput = await _couponManager.ResolveCouponDiscountInputAsync( + couponInstanceId, mainCustomerId, destinationCountry, cancellationToken); return MarketingBillingCalculator.Calculate( receivableSubtotal, + baseFreightAmount, level?.ExtraDiscountRate ?? 0M, level?.Id, level?.LevelName, @@ -76,7 +112,8 @@ public class MarketingOrderHelper : IScopedDependency public async Task> BuildAvailableCouponsAsync( int? customerId, - decimal? amountAfterLevel, + decimal? baseFreightAmount, + string? destinationCountry = null, CancellationToken cancellationToken = default) { if (!customerId.HasValue || customerId.Value <= 0) @@ -94,6 +131,7 @@ public class MarketingOrderHelper : IScopedDependency _customerRepository, sessionCustomer, cancellationToken); - return await _couponManager.BuildAvailableCouponsAsync(mainCustomer.Id, amountAfterLevel, cancellationToken); + return await _couponManager.BuildAvailableCouponsAsync( + mainCustomer.Id, baseFreightAmount, destinationCountry, cancellationToken); } } diff --git a/src/Oms.Application/Shared/MarketingPointsManager.cs b/src/Oms.Application/Shared/MarketingPointsManager.cs index 8be36fc..7f835c9 100644 --- a/src/Oms.Application/Shared/MarketingPointsManager.cs +++ b/src/Oms.Application/Shared/MarketingPointsManager.cs @@ -8,22 +8,22 @@ using Oms.Domain.MarketingAggregate.Consts; using Oms.Domain.MarketingAggregate.Entities; using Oms.Domain.MarketingAggregate.Enums; using Oms.Domain.MarketingAggregate.Repositories; -using Oms.Domain.OrdersAggregate.Repositories; using System.Text.Json; namespace Oms.Application.Shared; /// -/// 积分发放、调账与等级升级。 +/// 积分发放、调账与滚动 12 个月定级。 /// public class MarketingPointsManager : IScopedDependency { + public static readonly TimeSpan RollingWindow = TimeSpan.FromDays(365); + private readonly IMemberPointsRuleRepository _ruleRepository; private readonly ICustomerPointsAccountRepository _accountRepository; private readonly ICustomerPointsFlowRepository _flowRepository; private readonly IMemberLevelRepository _levelRepository; private readonly ICustomerRepository _customerRepository; - private readonly IOrderRepository _orderRepository; private readonly IMarketingAuditLogRepository _auditLogRepository; private readonly ICurrentUser _currentUser; @@ -33,7 +33,6 @@ public class MarketingPointsManager : IScopedDependency ICustomerPointsFlowRepository flowRepository, IMemberLevelRepository levelRepository, ICustomerRepository customerRepository, - IOrderRepository orderRepository, IMarketingAuditLogRepository auditLogRepository, ICurrentUser currentUser) { @@ -42,14 +41,18 @@ public class MarketingPointsManager : IScopedDependency _flowRepository = flowRepository; _levelRepository = levelRepository; _customerRepository = customerRepository; - _orderRepository = orderRepository; _auditLogRepository = auditLogRepository; _currentUser = currentUser; } - public async Task EarnOnOrderPaidAsync(int mainCustomerId, int orderId, decimal payableAmount, CancellationToken cancellationToken = default) + public async Task EarnOnOrderPaidAsync( + int mainCustomerId, + int orderId, + decimal paidBaseFreight, + string? bizNo, + CancellationToken cancellationToken = default) { - if (payableAmount <= 0) + if (paidBaseFreight <= 0) { return; } @@ -60,40 +63,53 @@ public class MarketingPointsManager : IScopedDependency } var rule = await _ruleRepository.GetActiveRuleAsync(cancellationToken); - if (rule == null) - { - return; - } - - var points = (int)Math.Floor(payableAmount * rule.PointsPerYuan); + var pointsPerYuan = rule?.PointsPerYuan > 0 ? rule.PointsPerYuan : 1M; + var points = (int)Math.Floor(paidBaseFreight * pointsPerYuan); if (points <= 0) { return; } - var isFirstOrder = !await _orderRepository.AnyAsync( - x => x.CustomerId == mainCustomerId && x.IsPaid > 0 && x.Id != orderId, - cancellationToken); - if (isFirstOrder && rule.FirstOrderMultiplier > 1) - { - points = (int)Math.Floor(points * rule.FirstOrderMultiplier); - } - - if (rule.MaxPointsPerOrder > 0) - { - points = Math.Min(points, rule.MaxPointsPerOrder); - } - await ApplyPointsChangeAsync( mainCustomerId, PointsChangeType.Earn, points, MarketingPointsBizTypes.OrderPay, orderId, - null, - "订单支付积分", + bizNo, + "实付基础运费积分", cancellationToken); - await TryUpgradeLevelAsync(mainCustomerId, cancellationToken); + await RecalcLevelAsync(mainCustomerId, allowDowngrade: false, cancellationToken); + } + + public async Task ClawbackOnOrderAsync( + int mainCustomerId, + int orderId, + string? bizNo, + string remark, + CancellationToken cancellationToken = default) + { + if (await _flowRepository.ExistsClawbackForOrderAsync(mainCustomerId, orderId, cancellationToken)) + { + return; + } + + var earned = await _flowRepository.GetEarnPointsForOrderAsync(mainCustomerId, orderId, cancellationToken); + if (earned <= 0) + { + return; + } + + await ApplyPointsChangeAsync( + mainCustomerId, + PointsChangeType.RefundClawback, + -earned, + MarketingPointsBizTypes.Refund, + orderId, + bizNo, + remark, + cancellationToken); + await RecalcLevelAsync(mainCustomerId, allowDowngrade: true, cancellationToken); } public async Task AdjustAsync(int mainCustomerId, int points, string remark, CancellationToken cancellationToken = default) @@ -123,10 +139,7 @@ public class MarketingPointsManager : IScopedDependency Remark = remark.Trim() }, cancellationToken); - if (points > 0) - { - await TryUpgradeLevelAsync(mainCustomerId, cancellationToken); - } + await RecalcLevelAsync(mainCustomerId, allowDowngrade: points < 0, cancellationToken); } public async Task GetOrCreateAccountAsync(int mainCustomerId, CancellationToken cancellationToken = default) @@ -148,6 +161,115 @@ public class MarketingPointsManager : IScopedDependency return account; } + public Task GetRollingPointsAsync(int mainCustomerId, CancellationToken cancellationToken = default) + { + var since = DateTime.Now.Subtract(RollingWindow); + return _flowRepository.SumPointsSinceAsync(mainCustomerId, since, cancellationToken); + } + + public async Task RecalcAllLevelsAsync(CancellationToken cancellationToken = default) + { + var accounts = await _accountRepository.GetListAsync(); + foreach (var account in accounts) + { + await RecalcLevelAsync(account.CustomerId, allowDowngrade: true, cancellationToken); + } + } + + public async Task RecalcLevelAsync( + int mainCustomerId, + bool allowDowngrade, + CancellationToken cancellationToken = default) + { + var rolling = await GetRollingPointsAsync(mainCustomerId, cancellationToken); + var levels = (await _levelRepository.GetAllEnabledOrderedAsync(cancellationToken)) + .OrderBy(x => x.MinPoints) + .ThenBy(x => x.Sort) + .ThenBy(x => x.Id) + .ToList(); + if (levels.Count == 0) + { + return; + } + + var targetByPoints = levels + .Where(x => x.MinPoints <= rolling) + .OrderByDescending(x => x.MinPoints) + .ThenByDescending(x => x.Sort) + .FirstOrDefault() ?? levels[0]; + + var customer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == mainCustomerId, cancellationToken); + if (customer == null) + { + return; + } + + MemberLevel? current = null; + if (customer.MemberLevelId.HasValue) + { + current = levels.FirstOrDefault(x => x.Id == customer.MemberLevelId.Value) + ?? await _levelRepository.FirstOrDefaultAsync(x => x.Id == customer.MemberLevelId.Value, cancellationToken); + } + + var next = ResolveNextLevel(levels, current, targetByPoints, allowDowngrade); + if (next == null || customer.MemberLevelId == next.Id) + { + return; + } + + customer.MemberLevelId = next.Id; + await _customerRepository.UpdateAsync(customer, cancellationToken); + } + + internal static MemberLevel? ResolveNextLevel( + IReadOnlyList orderedByMinPoints, + MemberLevel? current, + MemberLevel targetByPoints, + bool allowDowngrade) + { + if (orderedByMinPoints.Count == 0) + { + return targetByPoints; + } + + if (current == null) + { + return targetByPoints; + } + + if (targetByPoints.MinPoints > current.MinPoints) + { + return targetByPoints; + } + + if (targetByPoints.MinPoints == current.MinPoints) + { + return current; + } + + if (!allowDowngrade || !current.CanDowngrade) + { + return current; + } + + var index = -1; + for (var i = 0; i < orderedByMinPoints.Count; i++) + { + if (orderedByMinPoints[i].Id == current.Id || orderedByMinPoints[i].LevelCode == current.LevelCode) + { + index = i; + break; + } + } + + if (index <= 0) + { + return current; + } + + return orderedByMinPoints[index - 1]; + } + private async Task ApplyPointsChangeAsync( int mainCustomerId, PointsChangeType changeType, @@ -164,21 +286,13 @@ public class MarketingPointsManager : IScopedDependency } var account = await GetOrCreateAccountAsync(mainCustomerId, cancellationToken); - if (changeType == PointsChangeType.Earn || changeType == PointsChangeType.Adjust) + if (account.AvailablePoints + points < 0) { - if (points < 0 && account.AvailablePoints + points < 0) - { - throw new BusinessException("可用积分不足"); - } - - account.TotalPoints = Math.Max(0, account.TotalPoints + Math.Max(0, points)); - account.AvailablePoints += points; - } - else - { - account.AvailablePoints += points; + throw new BusinessException("可用积分不足"); } + account.AvailablePoints += points; + account.TotalPoints = Math.Max(0, account.TotalPoints + points); await _accountRepository.UpdateAsync(account, cancellationToken); await _flowRepository.InsertAsync(new CustomerPointsFlow { @@ -191,28 +305,4 @@ public class MarketingPointsManager : IScopedDependency Remark = remark }, cancellationToken); } - - private async Task TryUpgradeLevelAsync(int mainCustomerId, CancellationToken cancellationToken = default) - { - var account = await GetOrCreateAccountAsync(mainCustomerId, cancellationToken); - var levels = await _levelRepository.GetAllEnabledOrderedAsync(cancellationToken); - var target = levels - .Where(x => x.MinPoints <= account.TotalPoints) - .OrderByDescending(x => x.MinPoints) - .ThenByDescending(x => x.Sort) - .FirstOrDefault(); - if (target == null) - { - return; - } - - var customer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == mainCustomerId, cancellationToken); - if (customer == null || customer.MemberLevelId == target.Id) - { - return; - } - - customer.MemberLevelId = target.Id; - await _customerRepository.UpdateAsync(customer, cancellationToken); - } } diff --git a/src/Oms.Application/Shared/MarketingReferralBinder.cs b/src/Oms.Application/Shared/MarketingReferralBinder.cs index fa9795a..801643e 100644 --- a/src/Oms.Application/Shared/MarketingReferralBinder.cs +++ b/src/Oms.Application/Shared/MarketingReferralBinder.cs @@ -2,6 +2,7 @@ using Fake.Application; using Fake.Domain.Exceptions; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.MarketingAggregate.Entities; using Oms.Domain.MarketingAggregate.Enums; using Oms.Domain.MarketingAggregate.Repositories; @@ -14,6 +15,8 @@ public static class MarketingReferralBinder string? inviteCode, ICustomerReferralCodeRepository referralCodeRepository, ICustomerRepository customerRepository, + IMemberLevelRepository? memberLevelRepository = null, + IReferralCommissionRuleRepository? commissionRuleRepository = null, CancellationToken cancellationToken = default) { if (entity.ParentCustomerId.HasValue || entity.ReferrerCustomerId.HasValue) @@ -45,6 +48,31 @@ public static class MarketingReferralBinder throw new BusinessException("邀请码对应客户无效"); } + if (memberLevelRepository != null) + { + var minLevel = "V1"; + if (commissionRuleRepository != null) + { + var rule = await commissionRuleRepository.GetActiveRuleAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(rule?.MinInviteLevelCode)) + { + minLevel = rule.MinInviteLevelCode; + } + } + + MemberLevel? level = null; + if (referrer.MemberLevelId.HasValue) + { + level = await memberLevelRepository.FirstOrDefaultAsync( + x => x.Id == referrer.MemberLevelId.Value, cancellationToken); + } + + if (!MarketingInviteRewardCalculator.CanInvite(level?.LevelCode, minLevel)) + { + throw new BusinessException("邀请人暂无邀请资格"); + } + } + if (IsSameIdentity(entity, referrer)) { throw new BusinessException("不能使用自己的邀请码"); diff --git a/src/Oms.Application/Shared/ReferralCommissionManager.cs b/src/Oms.Application/Shared/ReferralCommissionManager.cs index 9a7106e..15f70d1 100644 --- a/src/Oms.Application/Shared/ReferralCommissionManager.cs +++ b/src/Oms.Application/Shared/ReferralCommissionManager.cs @@ -13,28 +13,31 @@ using System.Text.Json; namespace Oms.Application.Shared; /// -/// 引荐分成:派送成功触发、冻结结算。 +/// 引荐分成:派送成功按实付基础运费百分比计奖,T+N 工作日入运费余额。 /// public class ReferralCommissionManager : IScopedDependency { private readonly IReferralCommissionRuleRepository _ruleRepository; private readonly IReferralCommissionDetailRepository _detailRepository; private readonly ICustomerRepository _customerRepository; + private readonly IMemberLevelRepository _memberLevelRepository; private readonly IOrderRepository _orderRepository; - private readonly CommissionBalanceMutator _commissionBalanceMutator; + private readonly CustomerBalanceMutator _balanceMutator; public ReferralCommissionManager( IReferralCommissionRuleRepository ruleRepository, IReferralCommissionDetailRepository detailRepository, ICustomerRepository customerRepository, + IMemberLevelRepository memberLevelRepository, IOrderRepository orderRepository, - CommissionBalanceMutator commissionBalanceMutator) + CustomerBalanceMutator balanceMutator) { _ruleRepository = ruleRepository; _detailRepository = detailRepository; _customerRepository = customerRepository; + _memberLevelRepository = memberLevelRepository; _orderRepository = orderRepository; - _commissionBalanceMutator = commissionBalanceMutator; + _balanceMutator = balanceMutator; } public async Task OnOrderDeliverySuccessAsync(int orderId, CancellationToken cancellationToken = default) @@ -46,8 +49,7 @@ public class ReferralCommissionManager : IScopedDependency throw new BusinessException("订单未达到派送成功状态"); } - var payable = order.PayableAmount ?? 0M; - if (payable <= 0 || !order.CustomerId.HasValue) + if (order.IsRefunded > 0 || !order.CustomerId.HasValue) { return; } @@ -58,18 +60,15 @@ public class ReferralCommissionManager : IScopedDependency return; } - var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(_customerRepository, sessionCustomer, cancellationToken); - if (!mainCustomer.ReferrerCustomerId.HasValue || mainCustomer.ReferrerCustomerId.Value <= 0) + var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync( + _customerRepository, sessionCustomer, cancellationToken); + if (!mainCustomer.ReferrerCustomerId.HasValue || mainCustomer.ReferrerCustomerId.Value <= 0 + || mainCustomer.ReferrerCustomerId.Value == mainCustomer.Id) { return; } var referrerId = mainCustomer.ReferrerCustomerId.Value; - if (referrerId == mainCustomer.Id) - { - return; - } - var existing = await _detailRepository.GetByOrderAndReferrerAsync(orderId, referrerId, cancellationToken); if (existing != null) { @@ -82,44 +81,69 @@ public class ReferralCommissionManager : IScopedDependency return; } - var commission = MarketingBillingCalculator.CalculateCommission(payable, rule.BaseAmount, rule.CommissionAmount); - if (commission <= 0) + var referrer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == referrerId, cancellationToken); + if (referrer == null) { return; } - if (rule.MonthlyCap.HasValue && rule.MonthlyCap.Value > 0) + var referrerLevel = referrer.MemberLevelId.HasValue + ? await _memberLevelRepository.FirstOrDefaultAsync(x => x.Id == referrer.MemberLevelId.Value, cancellationToken) + : null; + if (!MarketingInviteRewardCalculator.CanInvite(referrerLevel?.LevelCode, rule.MinInviteLevelCode)) { - var now = DateTime.Now; - var settled = await _detailRepository.SumSettledCommissionInMonthAsync(referrerId, now.Year, now.Month, cancellationToken); - var remaining = rule.MonthlyCap.Value - settled; - if (remaining <= 0) - { - return; - } - - commission = Math.Min(commission, remaining); + return; } - var snapshot = JsonSerializer.Serialize(new + var familyIds = await ResolveFamilyCustomerIdsAsync(mainCustomer.Id, cancellationToken); + var completedOrders = await _orderRepository.GetListAsync( + x => x.CustomerId != null + && familyIds.Contains(x.CustomerId.Value) + && x.OrderStatus == OrderStatusCodes.DeliverySuccess + && x.IsRefunded == 0); + var ordered = completedOrders + .OrderBy(x => x.SignTime ?? x.PayTime ?? x.CreateTime) + .ThenBy(x => x.Id) + .ToList(); + var orderIndex = ordered.FindIndex(x => x.Id == order.Id) + 1; + if (orderIndex <= 0) { - rule.BaseAmount, - rule.CommissionAmount, - rule.FreezeDays, - rule.MonthlyCap - }); + orderIndex = ordered.Count + 1; + } + var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]); + var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight( + MarketingFeeHelper.SumReceivableBaseFreight(fees), + order.LevelDiscountAmount, + order.CouponDiscountAmount); + + var snapshot = ToRewardSnapshot(rule); + var completeTime = order.SignTime ?? DateTime.Now; + var decision = MarketingInviteRewardCalculator.Resolve( + paidBaseFreight, + orderIndex, + mainCustomer.CreateTime, + completeTime, + snapshot); + if (!decision.Eligible || decision.Amount <= 0) + { + return; + } + + var settleDue = MarketingInviteRewardCalculator.AddBusinessDays(completeTime, snapshot.SettleBusinessDays); await _detailRepository.InsertAsync(new ReferralCommissionDetail { ReferrerCustomerId = referrerId, RefereeCustomerId = mainCustomer.Id, - OrderId = orderId, + OrderId = order.Id, OrderNo = order.ServiceNo, - BasePayableAmount = payable, - CommissionAmount = commission, + BasePayableAmount = paidBaseFreight, + CommissionAmount = decision.Amount, Status = CommissionStatus.Pending, - EligibleTime = order.SignTime ?? DateTime.Now, - RuleSnapshotJson = snapshot + EligibleTime = completeTime, + SettleDueTime = settleDue, + RewardOrderIndex = decision.OrderIndex, + RuleSnapshotJson = JsonSerializer.Serialize(snapshot) }, cancellationToken); } @@ -128,13 +152,7 @@ public class ReferralCommissionManager : IScopedDependency var pendingList = await _detailRepository.GetPendingSettleAsync(DateTime.Now, batchSize, cancellationToken); foreach (var detail in pendingList) { - if (detail.EligibleTime == null) - { - continue; - } - - var freezeDays = ParseFreezeDays(detail.RuleSnapshotJson); - if (detail.EligibleTime.Value.AddDays(freezeDays) > DateTime.Now) + if (!IsDue(detail, DateTime.Now)) { continue; } @@ -145,12 +163,12 @@ public class ReferralCommissionManager : IScopedDependency continue; } - await _commissionBalanceMutator.CreditCommissionAsync( + await _balanceMutator.CreditReferralRewardAsync( referrer, detail.CommissionAmount, detail.Id, detail.OrderNo, - $"引荐分成 {detail.OrderNo}", + $"邀请奖励 {detail.OrderNo}", cancellationToken); detail.Status = CommissionStatus.Settled; @@ -159,26 +177,94 @@ public class ReferralCommissionManager : IScopedDependency } } - private static int ParseFreezeDays(string? snapshotJson) + public async Task ClawbackOnOrderRefundAsync(Order order, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(snapshotJson)) + var detail = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken); + if (detail == null || detail.Status == CommissionStatus.Cancelled) { - return 7; + return; } - try + if (detail.Status == CommissionStatus.Pending) { - using var doc = JsonDocument.Parse(snapshotJson); - if (doc.RootElement.TryGetProperty("FreezeDays", out var prop)) + detail.Status = CommissionStatus.Cancelled; + detail.CancelReason = "订单退款扣回"; + await _detailRepository.UpdateAsync(detail, cancellationToken); + return; + } + + if (detail.Status == CommissionStatus.Settled) + { + var referrer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == detail.ReferrerCustomerId, cancellationToken); + if (referrer != null) { - return prop.GetInt32(); + await _balanceMutator.DebitReferralClawbackAsync( + referrer, + detail.CommissionAmount, + detail.Id, + detail.OrderNo, + $"邀请奖励退款扣回 {detail.OrderNo}", + cancellationToken); } + + detail.Status = CommissionStatus.Cancelled; + detail.CancelReason = "订单退款扣回"; + await _detailRepository.UpdateAsync(detail, cancellationToken); } - catch + } + + private async Task ResolveFamilyCustomerIdsAsync(int mainCustomerId, CancellationToken cancellationToken) + { + var children = await _customerRepository.GetListAsync(x => x.ParentCustomerId == mainCustomerId); + return children.Select(x => x.Id).Append(mainCustomerId).ToArray(); + } + + private static bool IsDue(ReferralCommissionDetail detail, DateTime now) + { + if (detail.SettleDueTime.HasValue) { - // ignore + return detail.SettleDueTime.Value <= now; } - return 7; + if (detail.EligibleTime.HasValue) + { + var freezeDays = 7; + try + { + if (!string.IsNullOrWhiteSpace(detail.RuleSnapshotJson)) + { + using var doc = JsonDocument.Parse(detail.RuleSnapshotJson); + if (doc.RootElement.TryGetProperty("SettleBusinessDays", out var days)) + { + freezeDays = days.GetInt32(); + } + else if (doc.RootElement.TryGetProperty("FreezeDays", out var oldDays)) + { + freezeDays = oldDays.GetInt32(); + } + } + } + catch + { + // ignore + } + + return MarketingInviteRewardCalculator.AddBusinessDays(detail.EligibleTime.Value, freezeDays) <= now; + } + + return false; } + + private static InviteRewardRuleSnapshot ToRewardSnapshot(ReferralCommissionRule rule) => new() + { + MinPaidBaseFreight = rule.MinPaidBaseFreight > 0 ? rule.MinPaidBaseFreight : 50M, + FirstOrderRate = rule.FirstOrderRate > 0 ? rule.FirstOrderRate : 0.05M, + FirstOrderCap = rule.FirstOrderCap > 0 ? rule.FirstOrderCap : 50M, + FollowOrderRate = rule.FollowOrderRate > 0 ? rule.FollowOrderRate : 0.03M, + FollowOrderCap = rule.FollowOrderCap > 0 ? rule.FollowOrderCap : 30M, + FollowWindowDays = rule.FollowWindowDays > 0 ? rule.FollowWindowDays : 90, + MaxRewardOrders = rule.MaxRewardOrders > 0 ? rule.MaxRewardOrders : 6, + SettleBusinessDays = rule.SettleBusinessDays > 0 ? rule.SettleBusinessDays : rule.FreezeDays, + MinInviteLevelCode = string.IsNullOrWhiteSpace(rule.MinInviteLevelCode) ? "V1" : rule.MinInviteLevelCode + }; } diff --git a/src/Oms.Application/Workers/MarketingBackgroundWorker.cs b/src/Oms.Application/Workers/MarketingBackgroundWorker.cs index 54b466c..a1c2455 100644 --- a/src/Oms.Application/Workers/MarketingBackgroundWorker.cs +++ b/src/Oms.Application/Workers/MarketingBackgroundWorker.cs @@ -7,7 +7,7 @@ using Oms.Application.Shared; namespace Oms.Application.Workers; /// -/// 营销定时任务:释放过期券锁、结算 Pending 分成。 +/// 营销定时任务:释放过期券锁、过期券、到期提醒、结算 Pending 分成。 /// public class MarketingBackgroundWorker : BackgroundService { @@ -57,10 +57,30 @@ public class MarketingBackgroundWorker : BackgroundService var couponManager = scope.ServiceProvider.GetRequiredService(); await couponManager.ReleaseExpiredLocksAsync(BatchSize, cancellationToken); + await couponManager.ExpireOverdueAsync(BatchSize, cancellationToken); + await couponManager.NotifyExpiringAsync(BatchSize, cancellationToken); var commissionManager = scope.ServiceProvider.GetRequiredService(); await commissionManager.SettlePendingAsync(BatchSize, cancellationToken); + await RecalcMemberLevelsIfDueAsync(scope.ServiceProvider, cancellationToken); + await unitOfWork.CompleteAsync(cancellationToken); } + + private DateTime _lastLevelRecalcDate = DateTime.MinValue; + + private async Task RecalcMemberLevelsIfDueAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var today = DateTime.Now.Date; + if (DateTime.Now.Hour != 0 || _lastLevelRecalcDate >= today) + { + return; + } + + var pointsManager = services.GetRequiredService(); + await pointsManager.RecalcAllLevelsAsync(cancellationToken); + _lastLevelRecalcDate = today; + _logger.LogInformation("会员等级已按滚动 12 个月积分重算"); + } } diff --git a/src/Oms.Domain/MarketingAggregate/Consts/MarketingConsts.cs b/src/Oms.Domain/MarketingAggregate/Consts/MarketingConsts.cs index db354ea..360169d 100644 --- a/src/Oms.Domain/MarketingAggregate/Consts/MarketingConsts.cs +++ b/src/Oms.Domain/MarketingAggregate/Consts/MarketingConsts.cs @@ -23,6 +23,25 @@ public static class MarketingGrantChannels public const string SelfReceive = "selfReceive"; public const string Portal = "portal"; + + public const string Referral = "referral"; + + public const string Route = "route"; + + public const string Festival = "festival"; +} + +public static class MarketingCouponTemplateCodes +{ + public const string NewcomerFirst = "NEWCOMER_FIRST"; + + public const string NewcomerSecond = "NEWCOMER_SECOND"; + + public const string NewcomerStock = "NEWCOMER_STOCK"; + + public const string ReferralInviter = "REFERRAL_INVITER"; + + public const string ReferralInvitee = "REFERRAL_INVITEE"; } public static class MarketingPointsBizTypes diff --git a/src/Oms.Domain/MarketingAggregate/Entities/CouponInstance.cs b/src/Oms.Domain/MarketingAggregate/Entities/CouponInstance.cs index 60d1f4b..80738b5 100644 --- a/src/Oms.Domain/MarketingAggregate/Entities/CouponInstance.cs +++ b/src/Oms.Domain/MarketingAggregate/Entities/CouponInstance.cs @@ -26,4 +26,6 @@ public class CouponInstance : OmsAuditedAggregateRoot public int? OrderId { get; set; } public DateTime? LockExpireTime { get; set; } + + public byte ExpiryNoticeSent { get; set; } } diff --git a/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs b/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs index 21b51f3..54a2e7a 100644 --- a/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs +++ b/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs @@ -15,6 +15,8 @@ public class MemberLevel : OmsAuditedAggregateRoot public decimal ExtraDiscountRate { get; set; } + public bool CanDowngrade { get; set; } + public int Sort { get; set; } public string? IconUrl { get; set; } diff --git a/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionDetail.cs b/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionDetail.cs index 275778a..5c01878 100644 --- a/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionDetail.cs +++ b/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionDetail.cs @@ -23,6 +23,10 @@ public class ReferralCommissionDetail : OmsAuditedAggregateRoot public DateTime? EligibleTime { get; set; } + public DateTime? SettleDueTime { get; set; } + + public int RewardOrderIndex { get; set; } + public DateTime? SettleTime { get; set; } public string? RuleSnapshotJson { get; set; } diff --git a/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionRule.cs b/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionRule.cs index d657fcb..fc848a1 100644 --- a/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionRule.cs +++ b/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionRule.cs @@ -21,5 +21,23 @@ public class ReferralCommissionRule : OmsAuditedAggregateRoot public decimal? MonthlyCap { get; set; } + public string MinInviteLevelCode { get; set; } = "V1"; + + public decimal MinPaidBaseFreight { get; set; } = 50M; + + public decimal FirstOrderRate { get; set; } = 0.05M; + + public decimal FirstOrderCap { get; set; } = 50M; + + public decimal FollowOrderRate { get; set; } = 0.03M; + + public decimal FollowOrderCap { get; set; } = 30M; + + public int FollowWindowDays { get; set; } = 90; + + public int MaxRewardOrders { get; set; } = 6; + + public int SettleBusinessDays { get; set; } = 3; + public EnableStatus Status { get; set; } = EnableStatus.Enabled; } diff --git a/src/Oms.Domain/MarketingAggregate/Enums/MarketingEnums.cs b/src/Oms.Domain/MarketingAggregate/Enums/MarketingEnums.cs index 52c2222..ce53b22 100644 --- a/src/Oms.Domain/MarketingAggregate/Enums/MarketingEnums.cs +++ b/src/Oms.Domain/MarketingAggregate/Enums/MarketingEnums.cs @@ -15,6 +15,7 @@ public enum CouponTemplateStatus : byte public enum CouponInstanceStatus : byte { + PendingUnlock = 5, Available = 1, Locked = 2, Used = 3, @@ -53,5 +54,6 @@ public enum PointsChangeType : byte public enum CouponValidType : byte { FixedDate = 1, - DaysAfterReceive = 2 + DaysAfterReceive = 2, + DaysAfterUnlock = 3 } diff --git a/src/Oms.Domain/MarketingAggregate/Repositories/IMarketingRepositories.cs b/src/Oms.Domain/MarketingAggregate/Repositories/IMarketingRepositories.cs index bd75f2c..d0da7b6 100644 --- a/src/Oms.Domain/MarketingAggregate/Repositories/IMarketingRepositories.cs +++ b/src/Oms.Domain/MarketingAggregate/Repositories/IMarketingRepositories.cs @@ -26,6 +26,12 @@ public interface ICustomerPointsAccountRepository : IRepository { Task ExistsEarnForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default); + + Task ExistsClawbackForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default); + + Task GetEarnPointsForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default); + + Task SumPointsSinceAsync(int customerId, DateTime since, CancellationToken cancellationToken = default); } public interface ICouponTemplateRepository : IRepository @@ -39,7 +45,13 @@ public interface ICouponInstanceRepository : IRepository Task> GetAvailableByCustomerAsync(int customerId, CancellationToken cancellationToken = default); + Task> GetPendingUnlockByCustomerAsync(int customerId, CancellationToken cancellationToken = default); + Task> GetExpiredLocksAsync(DateTime now, int take, CancellationToken cancellationToken = default); + + Task> GetExpiredAvailableAsync(DateTime now, int take, CancellationToken cancellationToken = default); + + Task> GetExpiringAvailableAsync(DateTime from, DateTime to, int take, CancellationToken cancellationToken = default); } public interface IReferralCommissionRuleRepository : IRepository @@ -51,9 +63,18 @@ public interface IReferralCommissionDetailRepository : IRepository GetByOrderAndReferrerAsync(int orderId, int referrerCustomerId, CancellationToken cancellationToken = default); + Task GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default); + + Task SumByReferrerAsync(int referrerCustomerId, CommissionStatus status, CancellationToken cancellationToken = default); + Task SumSettledCommissionInMonthAsync(int referrerCustomerId, int year, int month, CancellationToken cancellationToken = default); Task> GetPendingSettleAsync(DateTime now, int take, CancellationToken cancellationToken = default); + + Task> ListByReferrerAndRefereesAsync( + int referrerCustomerId, + int[] refereeIds, + CancellationToken cancellationToken = default); } public interface ICommissionWithdrawRequestRepository : IRepository diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index 611dd00..3aea651 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -234,4 +234,6 @@ public class Order : OmsAuditedAggregateRoot /// 出单时实际使用的标签模板 Id(面单文件见 t_order_file.systemLabel)。 /// public int? LabelTemplateId { get; set; } + + public byte IsRefunded { get; set; } } diff --git a/src/Oms.SqlSugarCore/Marketing/MarketingRepositories.cs b/src/Oms.SqlSugarCore/Marketing/MarketingRepositories.cs index 075e769..f42c74d 100644 --- a/src/Oms.SqlSugarCore/Marketing/MarketingRepositories.cs +++ b/src/Oms.SqlSugarCore/Marketing/MarketingRepositories.cs @@ -49,6 +49,35 @@ public class CustomerPointsFlowRepository : OmsSqlSugarRepository ExistsEarnForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default) => AnyAsync(x => x.CustomerId == customerId && x.BizId == orderId && x.BizType == "orderPay" && x.ChangeType == PointsChangeType.Earn, cancellationToken); + + public Task ExistsClawbackForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default) + => AnyAsync( + x => x.CustomerId == customerId + && x.BizId == orderId + && x.ChangeType == PointsChangeType.RefundClawback, + cancellationToken); + + public async Task GetEarnPointsForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var flow = await db.Queryable() + .Where(x => x.CustomerId == customerId + && x.BizId == orderId + && x.BizType == "orderPay" + && x.ChangeType == PointsChangeType.Earn) + .OrderByDescending(x => x.Id) + .FirstAsync(cancellationToken); + return flow?.Points ?? 0; + } + + public async Task SumPointsSinceAsync(int customerId, DateTime since, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var sum = await db.Queryable() + .Where(x => x.CustomerId == customerId && x.CreateTime >= since) + .SumAsync(x => x.Points); + return sum; + } } public class CouponTemplateRepository : OmsSqlSugarRepository, ICouponTemplateRepository, IScopedDependency @@ -84,6 +113,15 @@ public class CouponInstanceRepository : OmsSqlSugarRepository, I .ToListAsync(cancellationToken); } + public async Task> GetPendingUnlockByCustomerAsync(int customerId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.CustomerId == customerId && x.Status == CouponInstanceStatus.PendingUnlock) + .OrderBy(x => x.Id) + .ToListAsync(cancellationToken); + } + public async Task> GetExpiredLocksAsync(DateTime now, int take, CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); @@ -92,6 +130,33 @@ public class CouponInstanceRepository : OmsSqlSugarRepository, I .Take(take) .ToListAsync(cancellationToken); } + + public async Task> GetExpiredAvailableAsync(DateTime now, int take, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => (x.Status == CouponInstanceStatus.Available + || x.Status == CouponInstanceStatus.Locked) + && x.ValidEndTime < now) + .Take(take) + .ToListAsync(cancellationToken); + } + + public async Task> GetExpiringAvailableAsync( + DateTime from, + DateTime to, + int take, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.Status == CouponInstanceStatus.Available + && x.ExpiryNoticeSent == 0 + && x.ValidEndTime >= from + && x.ValidEndTime <= to) + .Take(take) + .ToListAsync(cancellationToken); + } } public class ReferralCommissionRuleRepository : OmsSqlSugarRepository, IReferralCommissionRuleRepository, IScopedDependency @@ -111,6 +176,17 @@ public class ReferralCommissionDetailRepository : OmsSqlSugarRepository GetByOrderAndReferrerAsync(int orderId, int referrerCustomerId, CancellationToken cancellationToken = default) => FirstOrDefaultAsync(x => x.OrderId == orderId && x.ReferrerCustomerId == referrerCustomerId, cancellationToken); + public Task GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default) + => FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken); + + public async Task SumByReferrerAsync(int referrerCustomerId, CommissionStatus status, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.ReferrerCustomerId == referrerCustomerId && x.Status == status) + .SumAsync(x => x.CommissionAmount); + } + public async Task SumSettledCommissionInMonthAsync(int referrerCustomerId, int year, int month, CancellationToken cancellationToken = default) { var start = new DateTime(year, month, 1); @@ -129,7 +205,27 @@ public class ReferralCommissionDetailRepository : OmsSqlSugarRepository() - .Where(x => x.Status == CommissionStatus.Pending && x.EligibleTime != null) + .Where(x => x.Status == CommissionStatus.Pending + && x.EligibleTime != null + && ((x.SettleDueTime != null && x.SettleDueTime <= now) + || (x.SettleDueTime == null))) + .Take(take) + .ToListAsync(cancellationToken); + } + + public async Task> ListByReferrerAndRefereesAsync( + int referrerCustomerId, + int[] refereeIds, + CancellationToken cancellationToken = default) + { + if (refereeIds.Length == 0) + { + return []; + } + + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.ReferrerCustomerId == referrerCustomerId && refereeIds.Contains(x.RefereeCustomerId)) .ToListAsync(cancellationToken); } } diff --git a/src/Oms.Tests/MarketingInviteRewardCalculatorTests.cs b/src/Oms.Tests/MarketingInviteRewardCalculatorTests.cs new file mode 100644 index 0000000..e4cc024 --- /dev/null +++ b/src/Oms.Tests/MarketingInviteRewardCalculatorTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using Oms.Application.Shared; + +namespace Oms.Tests; + +public class MarketingInviteRewardCalculatorTests +{ + private static readonly InviteRewardRuleSnapshot Rule = new(); + + [Fact] + public void FirstOrder_ShouldTakeFivePercentCapped() + { + var decision = MarketingInviteRewardCalculator.Resolve( + 1000M, 1, DateTime.Today.AddDays(-1), DateTime.Today, Rule); + + decision.Eligible.Should().BeTrue(); + decision.Rate.Should().Be(0.05M); + decision.Amount.Should().Be(50M); + } + + [Fact] + public void BelowMinPaidBaseFreight_ShouldSkip() + { + var decision = MarketingInviteRewardCalculator.Resolve( + 49.99M, 1, DateTime.Today, DateTime.Today, Rule); + + decision.Eligible.Should().BeFalse(); + decision.Amount.Should().Be(0M); + } + + [Fact] + public void FollowOrder_ShouldTakeThreePercent() + { + var decision = MarketingInviteRewardCalculator.Resolve( + 200M, 2, DateTime.Today.AddDays(-10), DateTime.Today, Rule); + + decision.Eligible.Should().BeTrue(); + decision.Rate.Should().Be(0.03M); + decision.Amount.Should().Be(6M); + } + + [Fact] + public void FollowOrderOutsideWindow_ShouldSkip() + { + var decision = MarketingInviteRewardCalculator.Resolve( + 200M, 2, DateTime.Today.AddDays(-91), DateTime.Today, Rule); + + decision.Eligible.Should().BeFalse(); + } + + [Fact] + public void SeventhOrder_ShouldSkip() + { + var decision = MarketingInviteRewardCalculator.Resolve( + 200M, 7, DateTime.Today, DateTime.Today, Rule); + + decision.Eligible.Should().BeFalse(); + } + + [Fact] + public void CanInvite_V0ShouldFail_V1ShouldPass() + { + MarketingInviteRewardCalculator.CanInvite("V0", "V1").Should().BeFalse(); + MarketingInviteRewardCalculator.CanInvite("V1", "V1").Should().BeTrue(); + MarketingInviteRewardCalculator.CanInvite("normal", "V1").Should().BeFalse(); + MarketingInviteRewardCalculator.CanInvite("silver", "V1").Should().BeTrue(); + } + + [Fact] + public void AddBusinessDays_ShouldSkipWeekend() + { + var friday = new DateTime(2026, 10, 2); + var due = MarketingInviteRewardCalculator.AddBusinessDays(friday, 3); + due.Should().Be(new DateTime(2026, 10, 7)); + } +} diff --git a/src/Oms.Tests/MarketingMemberPointsTests.cs b/src/Oms.Tests/MarketingMemberPointsTests.cs new file mode 100644 index 0000000..bcced27 --- /dev/null +++ b/src/Oms.Tests/MarketingMemberPointsTests.cs @@ -0,0 +1,200 @@ +using FluentAssertions; +using Oms.Application.Dtos.Marketing; +using Oms.Application.Shared; +using Oms.Domain.MarketingAggregate.Entities; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Tests; + +public class MarketingMemberPointsTests +{ + [Fact] + public void MemberDiscount_ShouldApplyOnlyToBaseFreight() + { + var result = MarketingBillingCalculator.Calculate( + receivableSubtotal: 1200M, + baseFreightAmount: 1000M, + extraDiscountRate: 0.05M, + memberLevelId: 5, + memberLevelName: "皇冠会员", + priceTierKey: null, + coupon: null); + + result.LevelDiscountAmount.Should().Be(50M); + result.PayableAmount.Should().Be(1150M); + result.PaidBaseFreightAmount.Should().Be(950M); + result.CouponInstanceId.Should().BeNull(); + } + + [Fact] + public void CouponAndMember_ShouldPickCheaperWithoutStacking() + { + var coupon = new CouponDiscountInput + { + CouponInstanceId = 9, + CouponType = MarketingCouponTypeCodes.FixedOff, + FaceValue = 80M, + MinOrderAmount = 0M + }; + + var result = MarketingBillingCalculator.Calculate( + 1200M, + 1000M, + 0.05M, + 5, + "皇冠会员", + null, + coupon); + + result.LevelDiscountAmount.Should().Be(0M); + result.CouponDiscountAmount.Should().Be(80M); + result.PayableAmount.Should().Be(1120M); + result.CouponInstanceId.Should().Be(9); + result.PaidBaseFreightAmount.Should().Be(920M); + } + + [Fact] + public void Coupon_ShouldCapAtBaseFreightAndIgnoreSurcharge() + { + var coupon = new CouponDiscountInput + { + CouponInstanceId = 1, + CouponType = MarketingCouponTypeCodes.FixedOff, + FaceValue = 200M, + MinOrderAmount = 0M + }; + + var result = MarketingBillingCalculator.Calculate( + 150M, 100M, 0M, null, null, null, coupon); + + result.CouponDiscountAmount.Should().Be(100M); + result.PayableAmount.Should().Be(50M); + result.PaidBaseFreightAmount.Should().Be(0M); + } + + [Fact] + public void CouponThreshold_ShouldUseBaseFreightNotOrderTotal() + { + var coupon = new CouponDiscountInput + { + CouponInstanceId = 1, + CouponType = MarketingCouponTypeCodes.FixedOff, + FaceValue = 15M, + MinOrderAmount = 150M + }; + + var result = MarketingBillingCalculator.Calculate( + 250M, 140M, 0M, null, null, null, coupon); + + result.CouponDiscountAmount.Should().Be(0M); + result.CouponInstanceId.Should().BeNull(); + result.PayableAmount.Should().Be(250M); + } + + [Fact] + public void CouponScope_ShouldMatchCountryCodes() + { + MarketingCouponScope.Matches("""{"countryCodes":["MY","SG"]}""", "MY").Should().BeTrue(); + MarketingCouponScope.Matches("""{"countryCodes":["MY","SG"]}""", "US").Should().BeFalse(); + MarketingCouponScope.Matches(null, "MY").Should().BeTrue(); + } + + [Fact] + public void EqualOrWorseCoupon_ShouldKeepMemberDiscount() + { + var coupon = new CouponDiscountInput + { + CouponInstanceId = 9, + CouponType = MarketingCouponTypeCodes.FixedOff, + FaceValue = 50M + }; + + var result = MarketingBillingCalculator.Calculate( + 1200M, + 1000M, + 0.05M, + 5, + "皇冠会员", + null, + coupon); + + result.LevelDiscountAmount.Should().Be(50M); + result.CouponDiscountAmount.Should().Be(0M); + result.CouponInstanceId.Should().BeNull(); + result.PayableAmount.Should().Be(1150M); + } + + [Fact] + public void DiscountRate_ShouldCapAtFivePercent() + { + var result = MarketingBillingCalculator.Calculate( + 1000M, + 1000M, + 0.2M, + 1, + "测试", + null, + null); + + result.LevelDiscountAmount.Should().Be(50M); + } + + [Fact] + public void FuelSurcharge_ShouldNotCountAsBaseFreight() + { + var fees = new[] + { + new OrderFee { Type = OrderFeeTypeCodes.Receivable, CostItem = "freight", Amount = 100 }, + new OrderFee { Type = OrderFeeTypeCodes.Receivable, CostItem = "燃油附加费", Amount = 30 }, + new OrderFee { Type = OrderFeeTypeCodes.Receivable, CostItem = "偏远附加费", Amount = 20 } + }; + + MarketingFeeHelper.SumReceivableBaseFreight(fees).Should().Be(100M); + MarketingFeeHelper.IsBaseFreightCostItem("燃油附加费").Should().BeFalse(); + } + + [Fact] + public void V5_ShouldStepDownOneLevelOnly() + { + var levels = new List + { + new() { Id = 1, LevelCode = "V0", MinPoints = 0, CanDowngrade = false }, + new() { Id = 2, LevelCode = "V3", MinPoints = 120000, CanDowngrade = false }, + new() { Id = 3, LevelCode = "V4", MinPoints = 240000, CanDowngrade = true }, + new() { Id = 4, LevelCode = "V5", MinPoints = 600000, CanDowngrade = true } + }; + var v5 = levels[3]; + var v0Target = levels[0]; + + var next = MarketingPointsManager.ResolveNextLevel(levels, v5, v0Target, allowDowngrade: true); + next!.LevelCode.Should().Be("V4"); + } + + [Fact] + public void V3_ShouldNotDowngrade() + { + var levels = new List + { + new() { Id = 1, LevelCode = "V0", MinPoints = 0, CanDowngrade = false }, + new() { Id = 2, LevelCode = "V3", MinPoints = 120000, CanDowngrade = false } + }; + + var next = MarketingPointsManager.ResolveNextLevel(levels, levels[1], levels[0], allowDowngrade: true); + next!.LevelCode.Should().Be("V3"); + } + + [Fact] + public void Upgrade_CanJumpToMatchingLevel() + { + var levels = new List + { + new() { Id = 1, LevelCode = "V0", MinPoints = 0, CanDowngrade = false }, + new() { Id = 2, LevelCode = "V4", MinPoints = 240000, CanDowngrade = true }, + new() { Id = 3, LevelCode = "V5", MinPoints = 600000, CanDowngrade = true } + }; + + var next = MarketingPointsManager.ResolveNextLevel(levels, levels[0], levels[2], allowDowngrade: false); + next!.LevelCode.Should().Be("V5"); + } +} diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs index 36b5ce9..f76a3ed 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs @@ -159,6 +159,8 @@ public class WeChatCustomerService( input.InviteCode, referralCodeRepository, customerRepository, + memberLevelRepository, + LazyServiceProvider.GetRequiredService(), cancellationToken); if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue) { @@ -174,6 +176,10 @@ public class WeChatCustomerService( try { await couponManager.GrantNewcomerCouponsAsync(entity.Id, cancellationToken); + if (entity.ReferrerCustomerId.HasValue) + { + await couponManager.GrantInviteeCouponAsync(entity.Id, cancellationToken); + } } catch (Exception grantEx) { -- 2.45.2 From 6faadcf2a10db1fa58ec8fee57933fae75000820 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 10:57:21 +0800 Subject: [PATCH 28/41] =?UTF-8?q?feat:=20=E5=8D=96=E4=BB=B7=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E4=BB=B7=E6=A0=BC=E5=88=86=E5=8C=BA=E8=A1=A8?= =?UTF-8?q?=EF=BC=8C=E5=88=97=E8=A1=A8=E8=BF=94=E5=9B=9E=E5=88=9B=E5=BB=BA?= =?UTF-8?q?/=E4=BF=AE=E6=94=B9=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并多 Area 分区规则进发布快照,计费用规则自身 AreaId 匹配;产品与渠道列表补 CreateBy/UpdateBy。 Co-authored-by: Cursor --- .../Dtos/Channels/SalesProductListOutput.cs | 10 ++ .../Dtos/Channels/ServiceChannelListOutput.cs | 10 ++ .../Dtos/Orders/PublishSnapshotRuleDtos.cs | 6 + .../Channels/SalesProductService.Helpers.cs | 9 +- .../Services/Channels/SalesProductService.cs | 7 +- .../Channels/ServiceChannelService.Helpers.cs | 9 +- .../Channels/ServiceChannelService.cs | 4 +- .../Orders/ParcelBillingCalculator.cs | 13 +- .../Shared/KpiAreaSnapshotMapper.cs | 4 +- .../Shared/PriceAreaSnapshotHelper.cs | 161 +++++++++--------- .../Shared/RulesSnapshotHelper.cs | 2 +- 11 files changed, 144 insertions(+), 91 deletions(-) diff --git a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs index 3743ba9..9719866 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs @@ -100,8 +100,18 @@ public class SalesProductListOutput /// public DateTime CreateTime { get; set; } + /// + /// 创建人展示名。 + /// + public string? CreateBy { get; set; } + /// /// 更新时间。 /// public DateTime? UpdateTime { get; set; } + + /// + /// 修改人展示名。 + /// + public string? UpdateBy { get; set; } } diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs index 546f298..e491347 100644 --- a/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelListOutput.cs @@ -70,8 +70,18 @@ public class ServiceChannelListOutput /// public DateTime CreateTime { get; set; } + /// + /// 创建人展示名。 + /// + public string? CreateBy { get; set; } + /// /// 更新时间。 /// public DateTime? UpdateTime { get; set; } + + /// + /// 修改人展示名。 + /// + public string? UpdateBy { get; set; } } diff --git a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs index eb347f2..b02e6bc 100644 --- a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs +++ b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs @@ -11,6 +11,12 @@ public class KpiAreaSnapshotRuleDto { public int Id { get; set; } + /// 所属 KPI 分区表主键(多价格分区表合并快照时用于生成 zone 键)。 + public int AreaId { get; set; } + + /// 所属分区表名称(可选,便于展示与 zone 键兼容)。 + public string? AreaName { get; set; } + public string? CountryCode { get; set; } public string? Province { get; set; } diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 4deba9e..2f8f431 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -282,7 +282,6 @@ public partial class SalesProductService var priceSnapshot = await BuildPricePublishSnapshotAsync(salesProductId); ValidateBillingPriceItemRules( priceSnapshot.Prices.Select(p => MapPriceSaveInput(p, salesProductId)).ToList()); - await PriceAreaSnapshotHelper.EnsureSingleResolvedPriceAreaAsync(priceSnapshot, KpiAreaRepository); snapshot = priceSnapshot; } else @@ -519,7 +518,9 @@ public partial class SalesProductService return normalized.Count == 0 ? null : OmsDocHelper.SerializeJson(normalized); } - private static SalesProductListOutput MapListOutput(SalesProduct entity) + private static SalesProductListOutput MapListOutput( + SalesProduct entity, + IReadOnlyDictionary auditUserNameMap) { return new SalesProductListOutput { @@ -542,7 +543,9 @@ public partial class SalesProductService IsActive = (byte)entity.IsActive, Scheme = entity.Scheme, CreateTime = entity.CreateTime, - UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.CreateUserId, auditUserNameMap), + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime), + UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.UpdateUserId, auditUserNameMap) }; } diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index 5f75ce2..03cd705 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -45,9 +45,11 @@ public partial class SalesProductService : ApplicationService pageSize, new Dictionary { [nameof(SalesProduct.UpdateTime)] = false, [nameof(SalesProduct.Id)] = false }); var total = await SalesProductRepository.CountAsync(predicate); + var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync( + entities.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId })); return new OmsPageResult { - Items = entities.Select(MapListOutput).ToList(), + Items = entities.Select(entity => MapListOutput(entity, auditUserNameMap)).ToList(), Total = total }; } @@ -268,9 +270,6 @@ public partial class SalesProductService : ApplicationService { await GetProductOrThrowAsync(input.SalesProductId); ValidateBillingPriceItemRules(input.Prices); - await PriceAreaSnapshotHelper.EnsureSingleResolvedPriceAreaAsync( - BuildPriceSnapshotFromSaveInputs(input.SalesProductId, input.Prices), - KpiAreaRepository); var prices = new List(); var factors = new List(); var details = new List(); diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index eb303e8..e92c099 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -416,7 +416,10 @@ public partial class ServiceChannelService }; } - private static ServiceChannelListOutput MapListOutput(ServiceChannel entity, IReadOnlyDictionary groupMap) + private static ServiceChannelListOutput MapListOutput( + ServiceChannel entity, + IReadOnlyDictionary groupMap, + IReadOnlyDictionary auditUserNameMap) { return new ServiceChannelListOutput { @@ -433,7 +436,9 @@ public partial class ServiceChannelService RemoteAreaId = entity.RemoteAreaId, IsActive = (byte)entity.IsActive, CreateTime = entity.CreateTime, - UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.CreateUserId, auditUserNameMap), + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime), + UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.UpdateUserId, auditUserNameMap) }; } diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index 061ab28..ade005a 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -43,9 +43,11 @@ public partial class ServiceChannelService : ApplicationService new Dictionary { [nameof(ServiceChannel.UpdateTime)] = false, [nameof(ServiceChannel.Id)] = false }); var total = await ServiceChannelRepository.CountAsync(predicate); var groupMap = await GetGroupMapAsync(); + var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync( + entities.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId })); return new OmsPageResult { - Items = entities.Select(entity => MapListOutput(entity, groupMap)).ToList(), + Items = entities.Select(entity => MapListOutput(entity, groupMap, auditUserNameMap)).ToList(), Total = total }; } diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index c408031..5c5762c 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -125,7 +125,12 @@ internal static class ParcelBillingCalculator return null; } - var keys = BuildPriceZoneKeys(matched, priceSnapshot.PriceAreaName, priceSnapshot.PriceAreaId); + // 多价格分区表时优先用规则自身 AreaId,避免落到快照单一 PriceAreaId + var areaId = matched.AreaId > 0 ? matched.AreaId : priceSnapshot.PriceAreaId; + var areaName = !string.IsNullOrWhiteSpace(matched.AreaName) + ? matched.AreaName + : priceSnapshot.PriceAreaName; + var keys = BuildPriceZoneKeys(matched, areaName, areaId); return keys.Count > 0 ? keys : null; } @@ -155,7 +160,11 @@ internal static class ParcelBillingCalculator var allowed = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var rule in countryRules) { - foreach (var key in BuildPriceZoneKeys(rule, priceSnapshot.PriceAreaName, priceSnapshot.PriceAreaId)) + var areaId = rule.AreaId > 0 ? rule.AreaId : priceSnapshot.PriceAreaId; + var areaName = !string.IsNullOrWhiteSpace(rule.AreaName) + ? rule.AreaName + : priceSnapshot.PriceAreaName; + foreach (var key in BuildPriceZoneKeys(rule, areaName, areaId)) { if (!string.IsNullOrWhiteSpace(key)) { diff --git a/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs b/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs index 8644700..5d5a131 100644 --- a/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs +++ b/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs @@ -8,10 +8,12 @@ namespace Oms.Application.Shared; /// public static class KpiAreaSnapshotMapper { - public static KpiAreaSnapshotRuleDto MapToSnapshotRule(KpiAreaInfo entity) => + public static KpiAreaSnapshotRuleDto MapToSnapshotRule(KpiAreaInfo entity, string? areaName = null) => new() { Id = entity.Id, + AreaId = entity.AreaId, + AreaName = areaName, CountryCode = entity.CountryCode, Province = entity.Province, City = entity.City, diff --git a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs index 3cd3f06..67d560e 100644 --- a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs +++ b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs @@ -1,5 +1,3 @@ -using Fake.Application; -using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; using Oms.Domain.ChannelsAggregate.Entities; @@ -9,11 +7,12 @@ namespace Oms.Application.Shared; /// /// 将价格分区(t_kpi_area + t_kpi_area_info)固化进卖价发布快照。 +/// 支持卖价明细引用多张价格分区表:合并各表明细规则写入快照。 /// public static class PriceAreaSnapshotHelper { /// - /// 按产品 PriceAreaId(或从卖价明细分区推断)加载分区头与明细写入快照。 + /// 按产品 PriceAreaId(或从卖价明细分区推断的全部 AreaId)加载分区头与明细写入快照。 /// public static async Task ApplyPriceAreaToSnapshotAsync( SalesProductPricePublishSnapshotDto snapshot, @@ -21,14 +20,12 @@ public static class PriceAreaSnapshotHelper IKpiAreaRepository kpiAreaRepository, CancellationToken cancellationToken = default) { - await EnsureSingleResolvedPriceAreaAsync(snapshot, kpiAreaRepository, cancellationToken); - - var priceAreaId = await ResolvePriceAreaIdAsync( + var areaIds = await CollectResolvedPriceAreaIdsAsync( product, snapshot, kpiAreaRepository, cancellationToken); - if (!priceAreaId.HasValue) + if (areaIds.Count == 0) { snapshot.PriceAreaId = null; snapshot.PriceAreaName = null; @@ -36,84 +33,94 @@ public static class PriceAreaSnapshotHelper return; } - snapshot.PriceAreaId = priceAreaId; - var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value, cancellationToken); - snapshot.PriceAreaName = area?.Name; - var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value, cancellationToken); - snapshot.PriceAreaInfos = infos.Select(KpiAreaSnapshotMapper.MapToSnapshotRule).ToList(); + var mergedInfos = new List(); + var seenInfoIds = new HashSet(); + var areaNames = new List(); + + foreach (var areaId in areaIds) + { + var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken); + var areaName = area?.Name; + if (!string.IsNullOrWhiteSpace(areaName)) + { + areaNames.Add(areaName.Trim()); + } + + var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken); + foreach (var info in infos) + { + if (!seenInfoIds.Add(info.Id)) + { + continue; + } + + mergedInfos.Add(KpiAreaSnapshotMapper.MapToSnapshotRule(info, areaName)); + } + } + + snapshot.PriceAreaInfos = mergedInfos; + if (areaIds.Count == 1) + { + snapshot.PriceAreaId = areaIds[0]; + snapshot.PriceAreaName = areaNames.Count > 0 ? areaNames[0] : null; + } + else + { + // 多表:不再落单一 PriceAreaId,名称拼接便于人工查看 + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = areaNames.Count > 0 ? string.Join("、", areaNames) : null; + } } + /// + /// 收集卖价明细(及产品 PriceAreaId)可解析到的全部价格分区表 Id,去重保序。 + /// + internal static async Task> CollectResolvedPriceAreaIdsAsync( + SalesProduct product, + SalesProductPricePublishSnapshotDto snapshot, + IKpiAreaRepository kpiAreaRepository, + CancellationToken cancellationToken = default) + { + var result = new List(); + var seen = new HashSet(); + + void Add(int? areaId) + { + if (areaId is > 0 && seen.Add(areaId.Value)) + { + result.Add(areaId.Value); + } + } + + if (product.PriceAreaId is > 0) + { + Add(product.PriceAreaId); + } + + var zoneKeys = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshot); + foreach (var zoneKey in zoneKeys) + { + Add(await TryResolveZoneKeyToAreaIdAsync(zoneKey, kpiAreaRepository, cancellationToken)); + } + + return result; + } + + /// + /// 兼容旧调用:仅返回第一个可解析 AreaId(多表场景请用 CollectResolvedPriceAreaIdsAsync)。 + /// internal static async Task ResolvePriceAreaIdAsync( SalesProduct product, SalesProductPricePublishSnapshotDto snapshot, IKpiAreaRepository kpiAreaRepository, CancellationToken cancellationToken = default) { - if (product.PriceAreaId is > 0) - { - return product.PriceAreaId; - } - - var zoneKeys = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshot); - foreach (var zoneKey in zoneKeys) - { - if (!int.TryParse(zoneKey.Trim(), out var zoneId) || zoneId <= 0) - { - continue; - } - - var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == zoneId, cancellationToken); - if (area != null) - { - return zoneId; - } - - var info = await kpiAreaRepository.GetInfoByIdAsync(zoneId, cancellationToken); - if (info != null) - { - return info.AreaId; - } - } - - return null; - } - - /// - /// 校验卖价明细中可解析的分区因子键均指向同一 AreaId;混用多个分区表时抛业务异常。 - /// 非数字/无法解析的分区名(如「美西」)跳过,与 ResolvePriceAreaIdAsync 一致。 - /// - public static async Task EnsureSingleResolvedPriceAreaAsync( - SalesProductPricePublishSnapshotDto snapshot, - IKpiAreaRepository kpiAreaRepository, - CancellationToken cancellationToken = default) - { - var zoneKeys = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshot); - if (zoneKeys.Count == 0) - { - return; - } - - int? resolvedAreaId = null; - foreach (var zoneKey in zoneKeys) - { - var areaId = await TryResolveZoneKeyToAreaIdAsync(zoneKey, kpiAreaRepository, cancellationToken); - if (!areaId.HasValue) - { - continue; - } - - if (resolvedAreaId == null) - { - resolvedAreaId = areaId; - continue; - } - - if (resolvedAreaId.Value != areaId.Value) - { - throw new BusinessException( - $"卖价明细分区混用了多个价格分区(AreaId={resolvedAreaId} 与 {areaId}),请统一分区后再发布"); - } - } + var ids = await CollectResolvedPriceAreaIdsAsync( + product, + snapshot, + kpiAreaRepository, + cancellationToken); + return ids.Count > 0 ? ids[0] : null; } private static async Task TryResolveZoneKeyToAreaIdAsync( diff --git a/src/Oms.Application/Shared/RulesSnapshotHelper.cs b/src/Oms.Application/Shared/RulesSnapshotHelper.cs index 88f9062..9643354 100644 --- a/src/Oms.Application/Shared/RulesSnapshotHelper.cs +++ b/src/Oms.Application/Shared/RulesSnapshotHelper.cs @@ -98,7 +98,7 @@ public static class RulesSnapshotHelper snapshot, areaId, area?.Name, - infos.Select(KpiAreaSnapshotMapper.MapToSnapshotRule).ToList()); + infos.Select(info => KpiAreaSnapshotMapper.MapToSnapshotRule(info)).ToList()); } private static async Task ApplyWeightRuleToSnapshotAsync( -- 2.45.2 From a164d8c837708793aa3b444175eaccd52bb36fdc Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 13:36:19 +0800 Subject: [PATCH 29/41] =?UTF-8?q?feat:=20=E5=8D=96=E4=BB=B7/=E4=B9=B0?= =?UTF-8?q?=E4=BB=B7=E5=88=97=E8=A1=A8=E6=94=AF=E6=8C=81=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=98=8E=E7=BB=86=EF=BC=8C=E7=BC=A9=E7=9F=AD?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列表接口增加 includeDetails,并新增单条 price/get,供编辑弹窗按需拉取完整矩阵。 Co-authored-by: Cursor --- .../Channels/SalesProductService.Helpers.cs | 47 +++++++++++++++++-- .../Services/Channels/SalesProductService.cs | 16 ++++++- .../Channels/ServiceChannelService.Helpers.cs | 45 ++++++++++++++++-- .../Channels/ServiceChannelService.cs | 16 ++++++- 4 files changed, 110 insertions(+), 14 deletions(-) diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 2f8f431..7e2de52 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -101,17 +101,20 @@ public partial class SalesProductService /// /// 一次性装配价格项、因子和明细,避免接口层 N+1 查询。 + /// includeDetails=false 时跳过明细(列表页首屏),显著减小载荷。 /// - private async Task> GetPriceOutputsAsync(int salesProductId) + private async Task> GetPriceOutputsAsync(int salesProductId, bool includeDetails = true) { var prices = await SalesProductRepository.GetPricesAsync(salesProductId); var priceIds = prices.Select(x => x.Id).ToArray(); var factorMap = (await SalesProductRepository.GetPriceFactorsAsync(priceIds)) .GroupBy(x => x.PriceId) .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList()); - var detailMap = (await SalesProductRepository.GetPriceDetailsAsync(priceIds)) - .GroupBy(x => x.PriceId) - .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()); + var detailMap = includeDetails + ? (await SalesProductRepository.GetPriceDetailsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()) + : new Dictionary>(); var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync( prices.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId })); @@ -128,7 +131,9 @@ public partial class SalesProductService Currency = price.Currency, ChargingCondition = price.ChargingCondition, Factors = factorMap.TryGetValue(price.Id, out var factors) ? factors.Select(MapPriceFactorOutput).ToList() : [], - Details = detailMap.TryGetValue(price.Id, out var details) ? details.Select(MapPriceDetailOutput).ToList() : [], + Details = includeDetails && detailMap.TryGetValue(price.Id, out var details) + ? details.Select(MapPriceDetailOutput).ToList() + : [], CreateTime = price.CreateTime, CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(price.CreateUserId, auditUserNameMap), UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime), @@ -136,6 +141,38 @@ public partial class SalesProductService }).ToList(); } + /// + /// 单条价格项(含因子+明细),供编辑弹窗按需加载。 + /// + private async Task GetPriceOutputByIdAsync(int priceId) + { + var price = await GetPriceOrThrowAsync(priceId); + var priceIds = new[] { price.Id }; + var factors = await SalesProductRepository.GetPriceFactorsAsync(priceIds); + var details = await SalesProductRepository.GetPriceDetailsAsync(priceIds); + var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync( + [price.CreateUserId, price.UpdateUserId]); + + return new SalesProductPriceListOutput + { + Id = price.Id, + SalesProductId = price.SalesProductId, + SerialNumber = price.SerialNumber, + Status = (byte)price.Status, + PriceName = price.PriceName, + CostItem = price.CostItem, + CostUnit = price.CostUnit, + Currency = price.Currency, + ChargingCondition = price.ChargingCondition, + Factors = factors.OrderBy(item => item.SerialNumber).Select(MapPriceFactorOutput).ToList(), + Details = details.OrderBy(item => item.Id).Select(MapPriceDetailOutput).ToList(), + CreateTime = price.CreateTime, + CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(price.CreateUserId, auditUserNameMap), + UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime), + UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(price.UpdateUserId, auditUserNameMap) + }; + } + /// /// 获取排货方案,并补充服务渠道名称。 /// diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index 03cd705..5647829 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -169,17 +169,29 @@ public partial class SalesProductService : ApplicationService /// 查询销售产品价格项列表。 /// /// 入参:销售产品主键。 + /// 是否返回价格明细矩阵;列表页建议 false。 /// 销售产品价格项列表结果。 [HttpGet("/channel/sales-product/price/list")] - public async Task> GetPriceListAsync([FromQuery] int salesProductId) + public async Task> GetPriceListAsync( + [FromQuery] int salesProductId, + [FromQuery] bool includeDetails = true) { await GetProductOrThrowAsync(salesProductId); return new OmsItemsResult { - Items = await GetPriceOutputsAsync(salesProductId) + Items = await GetPriceOutputsAsync(salesProductId, includeDetails) }; } + /// + /// 获取单条销售产品价格项(含计费因子与价格明细)。 + /// + [HttpGet("/channel/sales-product/price/get")] + public async Task GetPriceAsync([FromQuery] int id) + { + return await GetPriceOutputByIdAsync(id); + } + /// /// 新增销售产品价格项。 /// diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index e92c099..1317ee5 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -146,17 +146,22 @@ public partial class ServiceChannelService /// /// 一次性装配买价项、因子和明细,避免接口层 N+1 查询。 + /// includeDetails=false 时跳过明细(列表页首屏),显著减小载荷。 /// - private async Task> GetPriceOutputsAsync(int serviceChannelId) + private async Task> GetPriceOutputsAsync( + int serviceChannelId, + bool includeDetails = true) { var prices = await ServiceChannelRepository.GetPricesAsync(serviceChannelId); var priceIds = prices.Select(x => x.Id).ToArray(); var factorMap = (await ServiceChannelRepository.GetPriceFactorsAsync(priceIds)) .GroupBy(x => x.PriceId) .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList()); - var detailMap = (await ServiceChannelRepository.GetPriceDetailsAsync(priceIds)) - .GroupBy(x => x.PriceId) - .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()); + var detailMap = includeDetails + ? (await ServiceChannelRepository.GetPriceDetailsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()) + : new Dictionary>(); return prices.Select(price => new ServiceChannelPriceListOutput { @@ -170,12 +175,42 @@ public partial class ServiceChannelService Currency = price.Currency, ChargingCondition = price.ChargingCondition, Factors = factorMap.TryGetValue(price.Id, out var factors) ? factors.Select(MapPriceFactorOutput).ToList() : [], - Details = detailMap.TryGetValue(price.Id, out var details) ? details.Select(MapPriceDetailOutput).ToList() : [], + Details = includeDetails && detailMap.TryGetValue(price.Id, out var details) + ? details.Select(MapPriceDetailOutput).ToList() + : [], CreateTime = price.CreateTime, UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime) }).ToList(); } + /// + /// 单条买价项(含因子+明细),供编辑弹窗按需加载。 + /// + private async Task GetPriceOutputByIdAsync(int priceId) + { + var price = await GetPriceOrThrowAsync(priceId); + var priceIds = new[] { price.Id }; + var factors = await ServiceChannelRepository.GetPriceFactorsAsync(priceIds); + var details = await ServiceChannelRepository.GetPriceDetailsAsync(priceIds); + + return new ServiceChannelPriceListOutput + { + Id = price.Id, + ServiceChannelId = price.ServiceChannelId, + SerialNumber = price.SerialNumber, + Status = (byte)price.Status, + PriceName = price.PriceName, + CostItem = price.CostItem, + CostUnit = price.CostUnit, + Currency = price.Currency, + ChargingCondition = price.ChargingCondition, + Factors = factors.OrderBy(item => item.SerialNumber).Select(MapPriceFactorOutput).ToList(), + Details = details.OrderBy(item => item.Id).Select(MapPriceDetailOutput).ToList(), + CreateTime = price.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime) + }; + } + /// /// 获取指定类型的发布记录。 /// diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index ade005a..fdad653 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -215,17 +215,29 @@ public partial class ServiceChannelService : ApplicationService /// 查询服务渠道价格项列表。 /// /// 入参:serviceChannelId。 + /// 是否返回价格明细矩阵;列表页建议 false。 /// 服务渠道价格项列表结果。 [HttpGet("/channel/service-channel/price/list")] - public async Task> GetPriceListAsync([FromQuery] int serviceChannelId) + public async Task> GetPriceListAsync( + [FromQuery] int serviceChannelId, + [FromQuery] bool includeDetails = true) { await GetEntityOrThrowAsync(serviceChannelId); return new OmsItemsResult { - Items = await GetPriceOutputsAsync(serviceChannelId) + Items = await GetPriceOutputsAsync(serviceChannelId, includeDetails) }; } + /// + /// 获取单条服务渠道价格项(含计费因子与价格明细)。 + /// + [HttpGet("/channel/service-channel/price/get")] + public async Task GetPriceAsync([FromQuery] int id) + { + return await GetPriceOutputByIdAsync(id); + } + /// /// 新增服务渠道价格项。 /// -- 2.45.2 From b38499963d438d544138f39ac0812debca902609 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 14:20:17 +0800 Subject: [PATCH 30/41] =?UTF-8?q?feat:=20=E9=94=80=E5=94=AE=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E9=A1=B5=E7=AD=BE=E6=8C=89=E9=9C=80=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=A4=B4=E5=92=8C=E6=B8=A0=E9=81=93=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=EF=BC=8C=E9=81=BF=E5=85=8D=E5=88=86=E9=A1=B5=E6=8B=89?= =?UTF-8?q?=E5=8F=96=E6=95=B4=E4=BB=BD=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Channels/SalesProductPublishListInput.cs | 5 ++ .../Channels/ServiceChannelOptionOutput.cs | 22 +++++++ .../Channels/SalesProductService.Helpers.cs | 65 ++++++++++++++----- .../Services/Channels/SalesProductService.cs | 9 +++ .../Channels/ServiceChannelService.cs | 16 +++++ .../Repositories/ISalesProductRepository.cs | 10 +++ .../Repositories/IServiceChannelRepository.cs | 5 ++ .../Channels/SalesProductRepository.cs | 42 ++++++++++++ .../Channels/ServiceChannelRepository.cs | 14 ++++ 9 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 src/Oms.Application/Dtos/Channels/ServiceChannelOptionOutput.cs diff --git a/src/Oms.Application/Dtos/Channels/SalesProductPublishListInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductPublishListInput.cs index a13648b..f50dc74 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductPublishListInput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductPublishListInput.cs @@ -19,4 +19,9 @@ public class SalesProductPublishListInput /// 每页条数。 /// public int PageSize { get; set; } + + /// + /// 是否返回发布快照正文。版本头只展示版本号和时间时传 false,避免传输并反序列化整份价格矩阵。 + /// + public bool IncludeContent { get; set; } = true; } diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelOptionOutput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelOptionOutput.cs new file mode 100644 index 0000000..1bb6205 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelOptionOutput.cs @@ -0,0 +1,22 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 服务渠道下拉项。只含展示所需字段,不分页。 +/// +public class ServiceChannelOptionOutput +{ + /// + /// 主键。 + /// + public int Id { get; set; } + + /// + /// 分组名称。 + /// + public string? GroupName { get; set; } + + /// + /// 渠道名称。 + /// + public string ChannelName { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 7e2de52..ff29681 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -213,32 +213,65 @@ public partial class SalesProductService private async Task> GetPublishListAsync(SalesProductPublishListInput input, string operatorType) { await GetProductOrThrowAsync(input.SalesProductId); + // 版本列表不传正文时一次返回全部版本头,页面没有分页。 + if (!input.IncludeContent) + { + var headers = await SalesProductRepository.GetAllPublishRecordHeadersAsync(input.SalesProductId, operatorType); + return new OmsPageResult + { + Items = headers.Select(entity => MapPublishRecordOutput(entity, includeContent: false)).ToList(), + Total = headers.Count + }; + } + var pageIndex = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); var entities = await SalesProductRepository.GetPublishRecordsAsync(input.SalesProductId, operatorType, pageIndex, pageSize); var total = await SalesProductRepository.CountPublishRecordsAsync(input.SalesProductId, operatorType); return new OmsPageResult { - Items = entities.Select(entity => new SalesProductPublishRecordOutput - { - Id = entity.Id, - SalesProductId = entity.SalesProductId, - Version = entity.Version, - OperatorType = entity.OperatorType, - Status = EnumDisplayNames.Of(entity.Status), - PublishUser = entity.PublishUser, - PublishTime = entity.PublishTime, - Description = entity.Description, - AiAnalysisReport = entity.AiAnalysisReport, - Content = operatorType == PriceOperatorType - ? OmsDocHelper.ParseSalesProductPricePublishContentForApi(entity.Content) - : OmsDocHelper.ToSnapshotApiContent(OmsDocHelper.DeserializeRulesSnapshot(entity.Content)), - CreateTime = entity.CreateTime - }).ToList(), + Items = entities.Select(entity => MapPublishRecordOutput(entity, includeContent: true)).ToList(), Total = total }; } + private SalesProductPublishRecordOutput MapPublishRecordOutput(SalesProductPublishRecord entity, bool includeContent) + { + var operatorType = entity.OperatorType; + return new SalesProductPublishRecordOutput + { + Id = entity.Id, + SalesProductId = entity.SalesProductId, + Version = entity.Version, + OperatorType = operatorType, + Status = EnumDisplayNames.Of(entity.Status), + PublishUser = entity.PublishUser, + PublishTime = entity.PublishTime, + Description = entity.Description, + AiAnalysisReport = includeContent ? entity.AiAnalysisReport : null, + Content = !includeContent + ? null + : string.Equals(operatorType, PriceOperatorType, StringComparison.OrdinalIgnoreCase) + ? OmsDocHelper.ParseSalesProductPricePublishContentForApi(entity.Content) + : OmsDocHelper.ToSnapshotApiContent(OmsDocHelper.DeserializeRulesSnapshot(entity.Content)), + CreateTime = entity.CreateTime + }; + } + + /// + /// 单条发布记录(含快照正文),供版本详情按需加载。 + /// + private async Task GetPublishRecordCoreAsync(int id) + { + var entity = await SalesProductRepository.GetPublishRecordByIdAsync(id); + if (entity == null) + { + throw new Fake.Application.BusinessException("发布记录不存在"); + } + + return MapPublishRecordOutput(entity, includeContent: true); + } + /// /// 组装规则发布快照(不落库),含黑名单/偏远/计重/进位明细。 /// diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index 5647829..e8e7fd1 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -386,6 +386,15 @@ public partial class SalesProductService : ApplicationService return GetPublishListAsync(input, RulesOperatorType); } + /// + /// 查询单条发布记录,含快照正文。 + /// + [HttpGet("/channel/sales-product/publish/get")] + public Task GetPublishRecordAsync([FromQuery] int id) + { + return GetPublishRecordCoreAsync(id); + } + /// /// 预览即将发布的快照 JSON(不落库),用于核对黑名单/计重等是否已固化进 Content。 /// diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index fdad653..a3b535d 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -25,6 +25,22 @@ public partial class ServiceChannelService : ApplicationService private OmsRbacUserDisplayNameResolver UserDisplayNameResolver => LazyServiceProvider.GetRequiredService(); + /// + /// 服务渠道下拉。返回全部渠道的名称,不分页。 + /// + [HttpGet("/channel/service-channel/options")] + public async Task> GetOptionsAsync() + { + var channels = await ServiceChannelRepository.GetOptionRowsAsync(); + var groupMap = await GetGroupMapAsync(); + return channels.Select(channel => new ServiceChannelOptionOutput + { + Id = channel.Id, + ChannelName = channel.ChannelName, + GroupName = channel.GroupId.HasValue && groupMap.TryGetValue(channel.GroupId.Value, out var name) ? name : null + }).ToList(); + } + /// /// 查询服务渠道列表。 /// diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs index 5ba942a..e72dc7f 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs @@ -50,6 +50,16 @@ public interface ISalesProductRepository : IRepository Task> GetPublishRecordsAsync(int salesProductId, string operatorType, int pageIndex, int pageSize, CancellationToken cancellationToken = default); + /// + /// 发布记录头(不含 Content / AI 报告),供版本号展示。 + /// + Task> GetPublishRecordHeadersAsync(int salesProductId, string operatorType, int pageIndex, int pageSize, CancellationToken cancellationToken = default); + + /// + /// 某产品某类发布记录的全部版本头,不含快照正文。 + /// + Task> GetAllPublishRecordHeadersAsync(int salesProductId, string operatorType, CancellationToken cancellationToken = default); + Task CountPublishRecordsAsync(int salesProductId, string operatorType, CancellationToken cancellationToken = default); Task InsertPublishRecordAsync(SalesProductPublishRecord record, CancellationToken cancellationToken = default); diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs index bb090e5..15fb005 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs @@ -15,6 +15,11 @@ public interface IServiceChannelRepository : IRepository Task> GetGroupsAsync(CancellationToken cancellationToken = default); + /// + /// 下拉选项:仅主键、分组、渠道名,不分页。 + /// + Task> GetOptionRowsAsync(CancellationToken cancellationToken = default); + Task> GetPricesAsync(int serviceChannelId, CancellationToken cancellationToken = default); Task GetPriceByIdAsync(int id, CancellationToken cancellationToken = default); diff --git a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs index fdc3af9..6fcf92a 100644 --- a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs @@ -186,6 +186,48 @@ public class SalesProductRepository : OmsSqlSugarRepository, ISale .ToPageListAsync(pageIndex, pageSize, cancellationToken); } + public async Task> GetPublishRecordHeadersAsync(int salesProductId, string operatorType, int pageIndex, int pageSize, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.SalesProductId == salesProductId && x.OperatorType == operatorType) + .OrderBy(x => x.PublishTime, SqlSugar.OrderByType.Desc) + .Select(x => new SalesProductPublishRecord + { + Id = x.Id, + SalesProductId = x.SalesProductId, + Version = x.Version, + OperatorType = x.OperatorType, + Status = x.Status, + PublishUser = x.PublishUser, + PublishTime = x.PublishTime, + Description = x.Description, + CreateTime = x.CreateTime + }) + .ToPageListAsync(pageIndex, pageSize, cancellationToken); + } + + public async Task> GetAllPublishRecordHeadersAsync(int salesProductId, string operatorType, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.SalesProductId == salesProductId && x.OperatorType == operatorType) + .OrderBy(x => x.PublishTime, SqlSugar.OrderByType.Desc) + .Select(x => new SalesProductPublishRecord + { + Id = x.Id, + SalesProductId = x.SalesProductId, + Version = x.Version, + OperatorType = x.OperatorType, + Status = x.Status, + PublishUser = x.PublishUser, + PublishTime = x.PublishTime, + Description = x.Description, + CreateTime = x.CreateTime + }) + .ToListAsync(cancellationToken); + } + public async Task CountPublishRecordsAsync(int salesProductId, string operatorType, CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); diff --git a/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs b/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs index c0c6e49..f7f9c4a 100644 --- a/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs @@ -35,6 +35,20 @@ public class ServiceChannelRepository : OmsSqlSugarRepository, I return await db.Queryable().ToListAsync(cancellationToken); } + public async Task> GetOptionRowsAsync(CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .OrderBy(x => x.Id) + .Select(x => new ServiceChannel + { + Id = x.Id, + GroupId = x.GroupId, + ChannelName = x.ChannelName + }) + .ToListAsync(cancellationToken); + } + public async Task> GetPricesAsync(int serviceChannelId, CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); -- 2.45.2 From bb8c0a906ccc8b9d6c776df1265136a4d6f551c1 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 16:42:42 +0800 Subject: [PATCH 31/41] =?UTF-8?q?feat:=20=E5=8D=96=E4=BB=B7/=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=8F=91=E5=B8=83=E9=92=89=20KPI=20=E5=88=86=E5=8C=BA?= =?UTF-8?q?=E5=8C=85=EF=BC=8C=E5=BF=AB=E7=85=A7=E5=8F=AA=E5=AD=98=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分区明细独立版本化并缓存加载,计费包缺失严格失败;同步态接口不再拉全文。 Co-authored-by: Cursor --- sql/migrate_kpi_area_publish_record.sql | 56 ++++ .../Channels/KpiAreaPublishRecordOutput.cs | 28 ++ .../SalesProductPublishSyncStatusOutput.cs | 22 ++ .../Dtos/Orders/KpiAreaPackRefDto.cs | 17 + .../Dtos/Orders/ProductBillingSnapshotDtos.cs | 35 +- .../Services/Channels/KpiAreaService.cs | 60 ++++ .../SalesProductService.BillingSimulate.cs | 39 ++- .../Channels/SalesProductService.Helpers.cs | 18 +- .../Services/Channels/SalesProductService.cs | 62 ++++ .../Orders/OrderService.ParcelBilling.cs | 90 +++-- .../Orders/OrderService.ParcelQuote.cs | 44 +-- .../Orders/PickupOrderFeePushService.cs | 11 +- .../Shared/KpiAreaPackService.cs | 317 ++++++++++++++++++ .../Shared/PriceAreaSnapshotHelper.cs | 72 ++-- .../Shared/RulesSnapshotHelper.cs | 80 +++-- .../Entities/KpiAreaPublishRecord.cs | 33 ++ .../Repositories/IKpiAreaPublishRepository.cs | 31 ++ .../Repositories/IKpiAreaRepository.cs | 2 +- .../Channels/KpiAreaRepository.cs | 78 +++++ .../RulesSnapshotHelperPublishTests.cs | 56 ++-- 20 files changed, 954 insertions(+), 197 deletions(-) create mode 100644 sql/migrate_kpi_area_publish_record.sql create mode 100644 src/Oms.Application/Dtos/Channels/KpiAreaPublishRecordOutput.cs create mode 100644 src/Oms.Application/Dtos/Channels/SalesProductPublishSyncStatusOutput.cs create mode 100644 src/Oms.Application/Dtos/Orders/KpiAreaPackRefDto.cs create mode 100644 src/Oms.Application/Shared/KpiAreaPackService.cs create mode 100644 src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaPublishRecord.cs create mode 100644 src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaPublishRepository.cs diff --git a/sql/migrate_kpi_area_publish_record.sql b/sql/migrate_kpi_area_publish_record.sql new file mode 100644 index 0000000..aa8e3c2 --- /dev/null +++ b/sql/migrate_kpi_area_publish_record.sql @@ -0,0 +1,56 @@ +-- 分区包发布表(价区 / 黑名单 / 偏远):卖价/规则发布钉包,计费按 version 加载 +SET @db := DATABASE(); + +SET @tbl := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_kpi_area_publish_record' +); +SET @sql := IF( + @tbl = 0, + 'CREATE TABLE `t_kpi_area_publish_record` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `AreaId` INT NOT NULL COMMENT ''所属分区表 t_kpi_area.Id'', + `AreaType` VARCHAR(32) NOT NULL COMMENT ''分区类型:price=价格分区,blacklist=黑名单,remote=偏远'', + `Version` VARCHAR(32) NOT NULL COMMENT ''包版本号(yyyyMMddHHmmssfff)'', + `Status` TINYINT NOT NULL DEFAULT 1 COMMENT ''状态:1=生效 Active,2=失效 Inactive'', + `ContentHash` VARCHAR(64) NOT NULL COMMENT ''规范化分区明细 Content 的 SHA256(hex),同 AreaId+Hash 去重复用'', + `Content` JSON NULL COMMENT ''分区明细快照 JSON(KpiAreaSnapshotRuleDto 数组)'', + `PublishUser` VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''发布人'', + `PublishTime` DATETIME NOT NULL COMMENT ''发布时间'', + `Description` VARCHAR(512) NULL COMMENT ''发布说明'', + `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''软删除:0=否,1=是'', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', + `UpdateUserId` INT NULL COMMENT ''更新人'', + `UpdateTime` DATETIME NULL COMMENT ''更新时间'', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_kpi_pack_area_hash` (`AreaId`, `ContentHash`), + KEY `idx_kpi_pack_area_ver` (`AreaId`, `Version`), + KEY `idx_kpi_pack_area_type_time` (`AreaId`, `AreaType`, `PublishTime`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''KPI分区发布包(价区/黑名单/偏远明细不可变快照)''', + 'SELECT ''t_kpi_area_publish_record already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 表已存在时回填表/字段注释(可重复执行;不包进 PREPARE) +ALTER TABLE `t_kpi_area_publish_record` + COMMENT = 'KPI分区发布包(价区/黑名单/偏远明细不可变快照)', + MODIFY COLUMN `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + MODIFY COLUMN `AreaId` INT NOT NULL COMMENT '所属分区表 t_kpi_area.Id', + MODIFY COLUMN `AreaType` VARCHAR(32) NOT NULL COMMENT '分区类型:price=价格分区,blacklist=黑名单,remote=偏远', + MODIFY COLUMN `Version` VARCHAR(32) NOT NULL COMMENT '包版本号(yyyyMMddHHmmssfff)', + MODIFY COLUMN `Status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=生效 Active,2=失效 Inactive', + MODIFY COLUMN `ContentHash` VARCHAR(64) NOT NULL COMMENT '规范化分区明细 Content 的 SHA256(hex),同 AreaId+Hash 去重复用', + MODIFY COLUMN `Content` JSON NULL COMMENT '分区明细快照 JSON(KpiAreaSnapshotRuleDto 数组)', + MODIFY COLUMN `PublishUser` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '发布人', + MODIFY COLUMN `PublishTime` DATETIME NOT NULL COMMENT '发布时间', + MODIFY COLUMN `Description` VARCHAR(512) NULL COMMENT '发布说明', + MODIFY COLUMN `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除:0=否,1=是', + MODIFY COLUMN `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + MODIFY COLUMN `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人', + MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间'; diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaPublishRecordOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaPublishRecordOutput.cs new file mode 100644 index 0000000..bf41312 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/KpiAreaPublishRecordOutput.cs @@ -0,0 +1,28 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// KPI 分区包版本头/详情。 +/// +public class KpiAreaPublishRecordOutput +{ + public int Id { get; set; } + + public int AreaId { get; set; } + + public string AreaType { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public string ContentHash { get; set; } = string.Empty; + + public string PublishUser { get; set; } = string.Empty; + + public DateTime PublishTime { get; set; } + + public string? Description { get; set; } + + /// 仅 get 接口返回正文。 + public object? Content { get; set; } +} diff --git a/src/Oms.Application/Dtos/Channels/SalesProductPublishSyncStatusOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductPublishSyncStatusOutput.cs new file mode 100644 index 0000000..ae388e3 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/SalesProductPublishSyncStatusOutput.cs @@ -0,0 +1,22 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 销售产品发布同步态(轻量,不含快照正文)。 +/// +public class SalesProductPublishSyncStatusOutput +{ + /// 相对最新生效版本是否有待发布变更。 + public bool Pending { get; set; } + + /// 最新生效版本号;无则 null。 + public string? OnlineVersion { get; set; } + + /// 最新生效发布时间。 + public DateTime? OnlinePublishTime { get; set; } + + /// 待发布快照内容哈希(规范化后)。 + public string? PendingContentHash { get; set; } + + /// 线上快照内容哈希。 + public string? OnlineContentHash { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/KpiAreaPackRefDto.cs b/src/Oms.Application/Dtos/Orders/KpiAreaPackRefDto.cs new file mode 100644 index 0000000..788a1f6 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/KpiAreaPackRefDto.cs @@ -0,0 +1,17 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 卖价/规则快照内对分区包的引用(明细在 t_kpi_area_publish_record)。 +/// +public class KpiAreaPackRefDto +{ + public int AreaId { get; set; } + + public string? AreaName { get; set; } + + /// 分区包 Version(yyyyMMddHHmmss)。 + public string PackVersion { get; set; } = string.Empty; + + /// 包 ContentHash,便于同步态对比。 + public string? ContentHash { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs index d3c698b..1140307 100644 --- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs +++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs @@ -1,9 +1,10 @@ +using System.Text.Json.Serialization; using Oms.Application.Dtos.Channels; namespace Oms.Application.Dtos.Orders; /// -/// 销售产品价格发布快照(与渠道发布 JSON 同构)。 +/// 销售产品价格发布快照。 /// public class SalesProductPricePublishSnapshotDto { @@ -13,7 +14,7 @@ public class SalesProductPricePublishSnapshotDto public int SalesProductId { get; set; } /// - /// 价格分区 ID(发布时固化,查价/计费优先用快照内分区规则)。 + /// 价格分区 ID(单表时冗余展示;以 PriceAreaRefs 为准)。 /// public int? PriceAreaId { get; set; } @@ -23,8 +24,14 @@ public class SalesProductPricePublishSnapshotDto public string? PriceAreaName { get; set; } /// - /// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为闭区间。 + /// 价格分区包引用(发布时钉包;明细在 t_kpi_area_publish_record)。 /// + public List PriceAreaRefs { get; set; } = []; + + /// + /// 运行时 hydrate / LIVE 用分区明细,不落发布 JSON。 + /// + [JsonIgnore] public List PriceAreaInfos { get; set; } = []; /// @@ -34,7 +41,7 @@ public class SalesProductPricePublishSnapshotDto } /// -/// 销售产品规则发布快照(与渠道发布 JSON 同构)。 +/// 销售产品规则发布快照。 /// public class SalesProductRulesPublishSnapshotDto { @@ -49,7 +56,7 @@ public class SalesProductRulesPublishSnapshotDto public List Providers { get; set; } = []; /// - /// 黑名单分区 ID(发布时固化)。 + /// 黑名单分区 ID。 /// public int? BlacklistAreaId { get; set; } @@ -59,12 +66,18 @@ public class SalesProductRulesPublishSnapshotDto public string? BlacklistAreaName { get; set; } /// - /// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。 + /// 黑名单分区包引用。 /// + public KpiAreaPackRefDto? BlacklistAreaRef { get; set; } + + /// + /// 运行时 hydrate / LIVE 用,不落发布 JSON。 + /// + [JsonIgnore] public List BlacklistAreaInfos { get; set; } = []; /// - /// 偏远分区 ID(发布时固化)。 + /// 偏远分区 ID。 /// public int? RemoteAreaId { get; set; } @@ -74,8 +87,14 @@ public class SalesProductRulesPublishSnapshotDto public string? RemoteAreaName { get; set; } /// - /// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。 + /// 偏远分区包引用。 /// + public KpiAreaPackRefDto? RemoteAreaRef { get; set; } + + /// + /// 运行时 hydrate / LIVE 用,不落发布 JSON。 + /// + [JsonIgnore] public List RemoteAreaInfos { get; set; } = []; /// diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs index 4a2d60f..ff902a5 100644 --- a/src/Oms.Application/Services/Channels/KpiAreaService.cs +++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs @@ -7,6 +7,7 @@ using Oms.Application.Dtos.Shared; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.Shared; using Oms.Domain.SystemAggregate.Repositories; namespace Oms.Application.Services.Channels; @@ -390,4 +391,63 @@ public class KpiAreaService : ApplicationService UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) }; } + + /// + /// 分区包版本列表(不含正文)。 + /// + [HttpGet("/channel/kpi-area/publish/list")] + public async Task> GetPublishListAsync([FromQuery] int areaId) + { + if (areaId <= 0) + { + throw new Fake.Application.BusinessException("分区表 Id 无效"); + } + + await GetAreaOrThrowAsync(areaId); + var headers = await KpiAreaRepository.GetPublishHeadersByAreaIdAsync(areaId); + return headers.Select(MapPublishHeader).ToList(); + } + + /// + /// 单条分区包(含正文)。 + /// + [HttpGet("/channel/kpi-area/publish/get")] + public async Task GetPublishRecordAsync([FromQuery] int id) + { + var entity = await KpiAreaRepository.GetPublishByIdAsync(id) + ?? throw new Fake.Application.BusinessException("分区包不存在"); + return MapPublishDetail(entity); + } + + private async Task GetAreaOrThrowAsync(int areaId) + { + var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId); + if (area == null) + { + throw new Fake.Application.BusinessException("分区表不存在"); + } + + return area; + } + + private static KpiAreaPublishRecordOutput MapPublishHeader(KpiAreaPublishRecord entity) => + new() + { + Id = entity.Id, + AreaId = entity.AreaId, + AreaType = entity.AreaType, + Version = entity.Version, + Status = EnumDisplayNames.Of(entity.Status), + ContentHash = entity.ContentHash, + PublishUser = entity.PublishUser, + PublishTime = entity.PublishTime, + Description = entity.Description + }; + + private static KpiAreaPublishRecordOutput MapPublishDetail(KpiAreaPublishRecord entity) + { + var output = MapPublishHeader(entity); + output.Content = OmsDocHelper.DeserializeJsonObject(entity.Content); + return output; + } } diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs index ed2bb5a..b126256 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs @@ -38,8 +38,28 @@ public partial class SalesProductService } var warnings = new List(); - var priceSnapshot = await BuildPricePublishSnapshotAsync(product.Id); - var rulesSnapshot = await BuildRulesPublishSnapshotAsync(product.Id); + // 模拟用当前草稿:LIVE Infos,不强制走已发布包 + var priceSnapshot = new SalesProductPricePublishSnapshotDto + { + SalesProductId = product.Id, + Prices = (await GetPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList() + }; + await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync( + priceSnapshot, + product, + KpiAreaRepository, + KpiAreaPackService); + var rulesSnapshot = new SalesProductRulesPublishSnapshotDto + { + Product = MapProductToRulesSaveInput(product) + }; + await RulesSnapshotHelper.ApplyLiveRulesToSnapshotAsync( + rulesSnapshot, + product, + KpiAreaRepository, + WeightRuleRepository, + WeightRoundingRepository, + KpiAreaPackService); var weight = new OrderWeight { @@ -261,7 +281,7 @@ public partial class SalesProductService return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); } - private async Task IsSimulateRemoteHitAsync( + private Task IsSimulateRemoteHitAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto rulesSnapshot, string countryCode, @@ -269,24 +289,17 @@ public partial class SalesProductService string? city, string? zipCode) { - var areaId = product.RemoteAreaId is > 0 ? product.RemoteAreaId : rulesSnapshot.RemoteAreaId; - if (areaId is not > 0) - { - return false; - } - if (rulesSnapshot.RemoteAreaInfos is { Count: > 0 }) { - return GeoPartitionMatcher.IsHit( + return Task.FromResult(GeoPartitionMatcher.IsHit( rulesSnapshot.RemoteAreaInfos, countryCode, province, city, - zipCode); + zipCode)); } - var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); - return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode); + return Task.FromResult(false); } private async Task ResolveSimulateWarehouseNameAsync(int? warehouseId) diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index ff29681..5ae3a5e 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -290,13 +290,15 @@ public partial class SalesProductService product, KpiAreaRepository, WeightRuleRepository, - WeightRoundingRepository); + WeightRoundingRepository, + KpiAreaPackService, + ResolvePublishUser()); await AllocationProviderSnapshotEnricher.EnrichAsync(rulesSnapshot.Providers); return rulesSnapshot; } /// - /// 组装价格发布快照(不落库)。 + /// 组装价格发布快照(不落库);价区钉分区包 Refs。 /// private async Task BuildPricePublishSnapshotAsync(int salesProductId) { @@ -306,10 +308,20 @@ public partial class SalesProductService SalesProductId = salesProductId, Prices = (await GetPriceOutputsAsync(salesProductId)).Select(PublishSnapshotSlimmer.SlimPrice).ToList() }; - await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(priceSnapshot, product, KpiAreaRepository); + await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync( + priceSnapshot, + product, + KpiAreaRepository, + KpiAreaPackService, + ResolvePublishUser()); return priceSnapshot; } + private string ResolvePublishUser() => + string.IsNullOrWhiteSpace(CurrentUser.UserName) + ? CurrentUser.GetId().ToString() + : CurrentUser.UserName!; + private static SalesProductSaveInput MapProductToRulesSaveInput(SalesProduct product) => new() { diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index e8e7fd1..35f2f94 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -23,6 +23,7 @@ public partial class SalesProductService : ApplicationService private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService(); private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService(); private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService(); + private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService(); private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher => LazyServiceProvider.GetRequiredService(); private OmsRbacUserDisplayNameResolver UserDisplayNameResolver => @@ -408,6 +409,67 @@ public partial class SalesProductService : ApplicationService return OmsDocHelper.ToSnapshotApiContent(snapshot)!; } + /// + /// 发布同步态(轻量):对比待发布与最新生效快照,不回传正文。 + /// + [HttpGet("/channel/sales-product/publish/sync-status")] + public async Task GetPublishSyncStatusAsync( + [FromQuery] int salesProductId, + [FromQuery] string operatorType) + { + var normalized = NormalizeOperatorType(operatorType); + await GetProductOrThrowAsync(salesProductId); + + object pendingSnapshot = normalized == PriceOperatorType + ? await BuildPricePublishSnapshotAsync(salesProductId) + : await BuildRulesPublishSnapshotAsync(salesProductId); + var pendingJson = OmsDocHelper.SerializeSnapshotJson(pendingSnapshot) ?? string.Empty; + var pendingHash = ComputeContentHash(pendingJson); + + var headers = await SalesProductRepository.GetAllPublishRecordHeadersAsync(salesProductId, normalized); + var onlineHeader = headers + .Where(x => x.Status == Domain.Shared.Enums.EsSnapshotStatus.Active) + .OrderByDescending(x => x.PublishTime) + .FirstOrDefault(); + + if (onlineHeader == null) + { + return new SalesProductPublishSyncStatusOutput + { + Pending = true, + OnlineVersion = null, + OnlinePublishTime = null, + PendingContentHash = pendingHash, + OnlineContentHash = null + }; + } + + var onlineRecord = await SalesProductRepository.GetPublishRecordByIdAsync(onlineHeader.Id); + var onlineJson = onlineRecord?.Content ?? string.Empty; + // 线上 Content 与待发布统一经反序列化再序列化,避免字段顺序噪声 + object? onlineNormalized = normalized == PriceOperatorType + ? OmsDocHelper.DeserializePriceSnapshot(onlineJson) + : OmsDocHelper.DeserializeRulesSnapshot(onlineJson); + var onlineCanon = OmsDocHelper.SerializeSnapshotJson(onlineNormalized) ?? string.Empty; + var onlineHash = ComputeContentHash(onlineCanon); + + return new SalesProductPublishSyncStatusOutput + { + Pending = !string.Equals(pendingHash, onlineHash, StringComparison.Ordinal), + OnlineVersion = onlineHeader.Version, + OnlinePublishTime = onlineHeader.PublishTime, + PendingContentHash = pendingHash, + OnlineContentHash = onlineHash + }; + } + + private static string ComputeContentHash(string content) + { + var hash = global::System.Security.Cryptography.SHA256.HashData( + global::System.Text.Encoding.UTF8.GetBytes(content)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + /// /// 执行销售产品发布。 /// diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 3423204..659aed6 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -51,6 +51,7 @@ public partial class OrderService private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService(); private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService(); private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService(); + private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService(); private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher => LazyServiceProvider.GetRequiredService(); @@ -552,7 +553,7 @@ public partial class OrderService throw new BusinessException("未命中规则快照版本(内存/Redis/DB)"); } - return new ProductSnapshotBinding + var binding = new ProductSnapshotBinding { RulesVersion = rulesRecord?.Version ?? $"LIVE-{product.Id}", PriceVersion = priceRecord?.Version ?? $"LIVE-{product.Id}", @@ -561,6 +562,8 @@ public partial class OrderService Rules = rulesSnapshot, Price = priceSnapshot }; + await KpiAreaPackService.HydrateBindingAsync(binding); + return binding; } private async Task ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order) @@ -632,7 +635,7 @@ public partial class OrderService priceEffectiveTime = priceRecord.PublishTime; } - return new ProductSnapshotBinding + var binding = new ProductSnapshotBinding { RulesVersion = rulesVersion, PriceVersion = priceVersion, @@ -641,6 +644,8 @@ public partial class OrderService Rules = rulesSnapshot, Price = priceSnapshot }; + await KpiAreaPackService.HydrateBindingAsync(binding); + return binding; } private async Task ResolveWarehouseNameAsync(int? warehouseId) @@ -1412,12 +1417,13 @@ public partial class OrderService Scheme = product.Scheme } }; - await RulesSnapshotHelper.ApplyRulesToSnapshotAsync( + await RulesSnapshotHelper.ApplyLiveRulesToSnapshotAsync( snapshot, product, KpiAreaRepository, WeightRuleRepository, - WeightRoundingRepository); + WeightRoundingRepository, + KpiAreaPackService); snapshot.Providers = await ResolveAllocationProvidersAsync(product, snapshot); return snapshot; } @@ -1455,7 +1461,11 @@ public partial class OrderService : [] })).ToList() }; - await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, KpiAreaRepository); + await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync( + snapshot, + product, + KpiAreaRepository, + KpiAreaPackService); return snapshot; } @@ -1484,7 +1494,7 @@ public partial class OrderService }; } - private async Task IsBlacklistHitAsync( + private Task IsBlacklistHitAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, string countryCode, @@ -1492,27 +1502,26 @@ public partial class OrderService string? city, string? zipCode) { - var areaId = ResolveEffectiveBlacklistAreaId(product, rulesSnapshot); - if (!areaId.HasValue) + if (rulesSnapshot?.BlacklistAreaRef != null && rulesSnapshot.BlacklistAreaInfos is not { Count: > 0 }) { - return false; + throw new BusinessException( + $"黑名单分区包未加载: areaId={rulesSnapshot.BlacklistAreaRef.AreaId} version={rulesSnapshot.BlacklistAreaRef.PackVersion}"); } if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 }) { - return GeoPartitionMatcher.IsHit( + return Task.FromResult(GeoPartitionMatcher.IsHit( rulesSnapshot.BlacklistAreaInfos, countryCode, province, city, - zipCode); + zipCode)); } - var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); - return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode); + return Task.FromResult(false); } - private async Task IsRemoteHitAsync( + private Task IsRemoteHitAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, string countryCode, @@ -1520,24 +1529,23 @@ public partial class OrderService string? city, string? zipCode) { - var areaId = ResolveEffectiveRemoteAreaId(product, rulesSnapshot); - if (!areaId.HasValue) + if (rulesSnapshot?.RemoteAreaRef != null && rulesSnapshot.RemoteAreaInfos is not { Count: > 0 }) { - return false; + throw new BusinessException( + $"偏远分区包未加载: areaId={rulesSnapshot.RemoteAreaRef.AreaId} version={rulesSnapshot.RemoteAreaRef.PackVersion}"); } if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 }) { - return GeoPartitionMatcher.IsHit( + return Task.FromResult(GeoPartitionMatcher.IsHit( rulesSnapshot.RemoteAreaInfos, countryCode, province, city, - zipCode); + zipCode)); } - var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); - return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode); + return Task.FromResult(false); } private static int? ResolveEffectiveBlacklistAreaId( @@ -1607,7 +1615,7 @@ public partial class OrderService return null; } - private async Task?> ResolvePriceZoneKeysAsync( + private Task?> ResolvePriceZoneKeysAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, SalesProductPricePublishSnapshotDto? priceSnapshot, @@ -1616,42 +1624,24 @@ public partial class OrderService string? city, string? zipCode) { + if (priceSnapshot?.PriceAreaRefs is { Count: > 0 } + && priceSnapshot.PriceAreaInfos is not { Count: > 0 }) + { + throw new BusinessException("价格分区包未加载,无法匹配分区"); + } + if (priceSnapshot?.PriceAreaInfos is { Count: > 0 }) { - return ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot( + return Task.FromResult(ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot( priceSnapshot, countryCode, province, city, - zipCode); + zipCode)); } - var priceAreaId = ResolveEffectivePriceAreaId(product, rulesSnapshot, priceSnapshot); - if (!priceAreaId.HasValue) - { - return null; - } - - var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value); - if (area == null) - { - return null; - } - - var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value); - var matched = GeoPartitionMatcher.MatchEntity( - rules, - countryCode, - province, - city, - zipCode); - if (matched == null) - { - return null; - } - - var keys = ParcelBillingCalculator.BuildPriceZoneKeys(matched, area); - return keys.Count > 0 ? keys : null; + // 无分区配置:返回 null;禁止回读线上 KPI(正式路径须走包) + return Task.FromResult?>(null); } private async Task ApplyChargeableWeightAsync( diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index 4000bae..7ef6839 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -737,7 +737,13 @@ public partial class OrderService } // 仅国家+重量:分区规则带邮编时无法精确命中,按「同国家」分区给出估算价; - // 快照中完全没有该国家时不跨国家兜底。 + // 须已 hydrate;禁止回读线上 KPI。 + if (snapshotBinding.Price?.PriceAreaRefs is { Count: > 0 } + && snapshotBinding.Price.PriceAreaInfos is not { Count: > 0 }) + { + throw new BusinessException("价格分区包未加载,无法估算分区"); + } + if (snapshotBinding.Price?.PriceAreaInfos is { Count: > 0 }) { foreach (var zoneKey in ParcelBillingCalculator.CollectCountryEstimateZoneKeys( @@ -746,42 +752,6 @@ public partial class OrderService { attempts.Add(new QuoteZoneAttempt([zoneKey], true, zoneKey)); } - - return attempts; - } - - // 无快照分区明细时,退回库表:同国家且不要求邮编的规则 - var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price); - if (priceAreaId is > 0) - { - var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value); - var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value); - foreach (var rule in rules) - { - if (!string.IsNullOrWhiteSpace(rule.CountryCode) - && !string.Equals(rule.CountryCode, context.CountryCode, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!string.IsNullOrWhiteSpace(rule.Province) || !string.IsNullOrWhiteSpace(rule.City)) - { - continue; - } - - var keys = ParcelBillingCalculator.BuildPriceZoneKeys(rule, area); - if (keys.Count == 0) - { - continue; - } - - if (attempts.Any(x => x.ZoneKeys.SequenceEqual(keys, StringComparer.OrdinalIgnoreCase))) - { - continue; - } - - attempts.Add(new QuoteZoneAttempt(keys.ToList(), true, keys[0])); - } } return attempts; diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs index 7cb00fd..19eef43 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs @@ -25,6 +25,7 @@ public class PickupOrderFeePushService : IScopedDependency private readonly IWeightRoundingRepository _weightRoundingRepository; private readonly IKpiAreaRepository _kpiAreaRepository; private readonly IWarehouseAddressRepository _warehouseAddressRepository; + private readonly KpiAreaPackService _kpiAreaPackService; public PickupOrderFeePushService( IPickupOrderRepository pickupOrderRepository, @@ -33,7 +34,8 @@ public class PickupOrderFeePushService : IScopedDependency IWeightRuleRepository weightRuleRepository, IWeightRoundingRepository weightRoundingRepository, IKpiAreaRepository kpiAreaRepository, - IWarehouseAddressRepository warehouseAddressRepository) + IWarehouseAddressRepository warehouseAddressRepository, + KpiAreaPackService kpiAreaPackService) { _pickupOrderRepository = pickupOrderRepository; _salesProductRepository = salesProductRepository; @@ -42,6 +44,7 @@ public class PickupOrderFeePushService : IScopedDependency _weightRoundingRepository = weightRoundingRepository; _kpiAreaRepository = kpiAreaRepository; _warehouseAddressRepository = warehouseAddressRepository; + _kpiAreaPackService = kpiAreaPackService; } public async Task PushFeeAsync(PickupOpenApiPushFeeInput? input) @@ -245,7 +248,11 @@ public class PickupOrderFeePushService : IScopedDependency SalesProductId = product.Id, Prices = (await GetSellPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList() }; - await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, _kpiAreaRepository); + await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync( + snapshot, + product, + _kpiAreaRepository, + _kpiAreaPackService); return snapshot; } diff --git a/src/Oms.Application/Shared/KpiAreaPackService.cs b/src/Oms.Application/Shared/KpiAreaPackService.cs new file mode 100644 index 0000000..4eb0ac3 --- /dev/null +++ b/src/Oms.Application/Shared/KpiAreaPackService.cs @@ -0,0 +1,317 @@ +using System.Security.Cryptography; +using System.Text; +using Fake.Application; +using Fake.DependencyInjection; +using Microsoft.Extensions.Caching.Memory; +using Oms.Application.Dtos.Orders; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.Shared.Enums; + +namespace Oms.Application.Shared; + +/// +/// KPI 分区包:发布时 Ensure(ContentHash 去重),计费按 version 加载(缓存),缺失严格失败。 +/// +public class KpiAreaPackService : IScopedDependency +{ + public const string AreaTypePrice = "price"; + public const string AreaTypeBlacklist = "blacklist"; + public const string AreaTypeRemote = "remote"; + + private static readonly MemoryCache PackCache = new(new MemoryCacheOptions + { + SizeLimit = 256 + }); + + private readonly IKpiAreaRepository _kpiAreaRepository; + + public KpiAreaPackService(IKpiAreaRepository kpiAreaRepository) + { + _kpiAreaRepository = kpiAreaRepository; + } + + /// + /// 按当前库明细组包;同 AreaId+ContentHash 复用已有 Version。 + /// + public async Task EnsurePackAsync( + int areaId, + string areaType, + string? publishUser = null, + string? description = null, + CancellationToken cancellationToken = default) + { + if (areaId <= 0) + { + throw new BusinessException("分区表 Id 无效"); + } + + var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken) + ?? throw new BusinessException($"分区表不存在: {areaId}"); + + var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken); + var rules = infos.Select(info => KpiAreaSnapshotMapper.MapToSnapshotRule(info, area.Name)).ToList(); + PublishSnapshotNormalizer.NormalizeKpiAreas(rules); + rules = rules.OrderBy(x => x.Id).ToList(); + + var contentJson = OmsDocHelper.SerializeSnapshotJson(rules) ?? "[]"; + var contentHash = ComputeSha256Hex(contentJson); + + var existing = await _kpiAreaRepository.FindPublishByAreaIdAndContentHashAsync( + areaId, + contentHash, + cancellationToken); + if (existing != null) + { + return ToRef(existing, area.Name); + } + + var version = DateTime.Now.ToString("yyyyMMddHHmmssfff"); + var record = new KpiAreaPublishRecord + { + AreaId = areaId, + AreaType = string.IsNullOrWhiteSpace(areaType) ? area.AreaType : areaType.Trim(), + Version = version, + Status = EsSnapshotStatus.Active, + ContentHash = contentHash, + Content = contentJson, + PublishUser = string.IsNullOrWhiteSpace(publishUser) ? "system" : publishUser.Trim(), + PublishTime = DateTime.Now, + Description = description + }; + record = await _kpiAreaRepository.InsertPublishRecordAsync(record, cancellationToken); + PackCache.Set(CacheKey(areaId, record.Version), rules, new MemoryCacheEntryOptions + { + Size = 1, + SlidingExpiration = TimeSpan.FromHours(6) + }); + return ToRef(record, area.Name); + } + + /// + /// 按引用加载分区规则;包不存在则严格失败。 + /// + public async Task> GetRulesOrThrowAsync( + KpiAreaPackRefDto? packRef, + CancellationToken cancellationToken = default) + { + if (packRef == null || packRef.AreaId <= 0 || string.IsNullOrWhiteSpace(packRef.PackVersion)) + { + throw new BusinessException("分区包引用无效"); + } + + return await GetRulesOrThrowAsync(packRef.AreaId, packRef.PackVersion, cancellationToken); + } + + public async Task> GetRulesOrThrowAsync( + int areaId, + string packVersion, + CancellationToken cancellationToken = default) + { + var key = CacheKey(areaId, packVersion); + if (PackCache.TryGetValue(key, out List? cached) && cached != null) + { + return cached; + } + + var record = await _kpiAreaRepository.GetPublishByAreaIdAndVersionAsync( + areaId, + packVersion, + cancellationToken); + if (record == null || string.IsNullOrWhiteSpace(record.Content)) + { + throw new BusinessException($"分区包不存在: areaId={areaId} version={packVersion}"); + } + + var rules = OmsDocHelper.DeserializeJson>(record.Content) ?? []; + PublishSnapshotNormalizer.NormalizeKpiAreas(rules); + PackCache.Set(key, rules, new MemoryCacheEntryOptions + { + Size = 1, + SlidingExpiration = TimeSpan.FromHours(6) + }); + return rules; + } + + /// + /// 将卖价快照中的 PriceAreaRefs hydrate 到 PriceAreaInfos(运行时,不回写库)。 + /// + public async Task HydratePriceSnapshotAsync( + SalesProductPricePublishSnapshotDto snapshot, + CancellationToken cancellationToken = default) + { + // LIVE/模拟:无 Refs,Infos 已由 LoadLive 填充,勿清空 + if (snapshot.PriceAreaRefs is not { Count: > 0 }) + { + return; + } + + var merged = new List(); + var seen = new HashSet(); + foreach (var packRef in snapshot.PriceAreaRefs) + { + var rules = await GetRulesOrThrowAsync(packRef, cancellationToken); + foreach (var rule in rules) + { + if (seen.Add(rule.Id)) + { + merged.Add(rule); + } + } + } + + snapshot.PriceAreaInfos = merged; + if (snapshot.PriceAreaRefs.Count == 1) + { + snapshot.PriceAreaId = snapshot.PriceAreaRefs[0].AreaId; + snapshot.PriceAreaName = snapshot.PriceAreaRefs[0].AreaName; + } + else + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = string.Join( + "、", + snapshot.PriceAreaRefs + .Select(x => x.AreaName) + .Where(x => !string.IsNullOrWhiteSpace(x))); + } + } + + /// + /// 将规则快照中的黑名单/偏远 Ref hydrate 到 Infos。 + /// + public async Task HydrateRulesSnapshotAsync( + SalesProductRulesPublishSnapshotDto snapshot, + CancellationToken cancellationToken = default) + { + if (snapshot.BlacklistAreaRef != null) + { + snapshot.BlacklistAreaInfos = await GetRulesOrThrowAsync(snapshot.BlacklistAreaRef, cancellationToken); + snapshot.BlacklistAreaId = snapshot.BlacklistAreaRef.AreaId; + snapshot.BlacklistAreaName = snapshot.BlacklistAreaRef.AreaName; + } + // else:LIVE 保留已有 Infos + + if (snapshot.RemoteAreaRef != null) + { + snapshot.RemoteAreaInfos = await GetRulesOrThrowAsync(snapshot.RemoteAreaRef, cancellationToken); + snapshot.RemoteAreaId = snapshot.RemoteAreaRef.AreaId; + snapshot.RemoteAreaName = snapshot.RemoteAreaRef.AreaName; + } + } + + public async Task HydrateBindingAsync( + ProductSnapshotBinding binding, + CancellationToken cancellationToken = default) + { + await HydratePriceSnapshotAsync(binding.Price, cancellationToken); + await HydrateRulesSnapshotAsync(binding.Rules, cancellationToken); + } + + /// + /// 从当前库装载分区规则到 Infos(仅 LIVE/模拟;不写包、不写 Ref)。 + /// + public async Task LoadLiveInfosIntoPriceSnapshotAsync( + SalesProductPricePublishSnapshotDto snapshot, + IReadOnlyList areaIds, + CancellationToken cancellationToken = default) + { + var merged = new List(); + var seen = new HashSet(); + var names = new List(); + foreach (var areaId in areaIds.Where(id => id > 0).Distinct()) + { + var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken); + if (!string.IsNullOrWhiteSpace(area?.Name)) + { + names.Add(area.Name.Trim()); + } + + var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken); + foreach (var info in infos) + { + if (!seen.Add(info.Id)) + { + continue; + } + + merged.Add(KpiAreaSnapshotMapper.MapToSnapshotRule(info, area?.Name)); + } + } + + PublishSnapshotNormalizer.NormalizeKpiAreas(merged); + snapshot.PriceAreaInfos = merged; + snapshot.PriceAreaRefs = []; + if (areaIds.Count == 1) + { + snapshot.PriceAreaId = areaIds[0]; + snapshot.PriceAreaName = names.Count > 0 ? names[0] : null; + } + else + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = names.Count > 0 ? string.Join("、", names) : null; + } + } + + public async Task LoadLiveInfosIntoRulesSnapshotAsync( + SalesProductRulesPublishSnapshotDto snapshot, + int? blacklistAreaId, + int? remoteAreaId, + CancellationToken cancellationToken = default) + { + snapshot.BlacklistAreaRef = null; + snapshot.RemoteAreaRef = null; + if (blacklistAreaId is > 0) + { + var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == blacklistAreaId.Value, cancellationToken); + var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(blacklistAreaId.Value, cancellationToken); + var rules = infos.Select(i => KpiAreaSnapshotMapper.MapToSnapshotRule(i, area?.Name)).ToList(); + PublishSnapshotNormalizer.NormalizeKpiAreas(rules); + snapshot.BlacklistAreaId = blacklistAreaId; + snapshot.BlacklistAreaName = area?.Name; + snapshot.BlacklistAreaInfos = rules; + } + else + { + snapshot.BlacklistAreaId = null; + snapshot.BlacklistAreaName = null; + snapshot.BlacklistAreaInfos = []; + } + + if (remoteAreaId is > 0) + { + var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == remoteAreaId.Value, cancellationToken); + var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(remoteAreaId.Value, cancellationToken); + var rules = infos.Select(i => KpiAreaSnapshotMapper.MapToSnapshotRule(i, area?.Name)).ToList(); + PublishSnapshotNormalizer.NormalizeKpiAreas(rules); + snapshot.RemoteAreaId = remoteAreaId; + snapshot.RemoteAreaName = area?.Name; + snapshot.RemoteAreaInfos = rules; + } + else + { + snapshot.RemoteAreaId = null; + snapshot.RemoteAreaName = null; + snapshot.RemoteAreaInfos = []; + } + } + + private static KpiAreaPackRefDto ToRef(KpiAreaPublishRecord record, string? areaName) => + new() + { + AreaId = record.AreaId, + AreaName = areaName, + PackVersion = record.Version, + ContentHash = record.ContentHash + }; + + private static string CacheKey(int areaId, string version) => + $"kpi-area-pack:{areaId}:{version}"; + + private static string ComputeSha256Hex(string content) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs index 67d560e..fbcdcae 100644 --- a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs +++ b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs @@ -6,18 +6,19 @@ using Oms.Domain.ChannelsAggregate.Repositories; namespace Oms.Application.Shared; /// -/// 将价格分区(t_kpi_area + t_kpi_area_info)固化进卖价发布快照。 -/// 支持卖价明细引用多张价格分区表:合并各表明细规则写入快照。 +/// 卖价发布:解析价区表 Id 并钉分区包引用(不内嵌 Infos)。 /// public static class PriceAreaSnapshotHelper { /// - /// 按产品 PriceAreaId(或从卖价明细分区推断的全部 AreaId)加载分区头与明细写入快照。 + /// 写入 PriceAreaRefs(Ensure 分区包);Infos 仅 LIVE 路径由 PackService.LoadLive 填充。 /// public static async Task ApplyPriceAreaToSnapshotAsync( SalesProductPricePublishSnapshotDto snapshot, SalesProduct product, IKpiAreaRepository kpiAreaRepository, + KpiAreaPackService packService, + string? publishUser = null, CancellationToken cancellationToken = default) { var areaIds = await CollectResolvedPriceAreaIdsAsync( @@ -25,53 +26,59 @@ public static class PriceAreaSnapshotHelper snapshot, kpiAreaRepository, cancellationToken); + snapshot.PriceAreaInfos = []; if (areaIds.Count == 0) { snapshot.PriceAreaId = null; snapshot.PriceAreaName = null; - snapshot.PriceAreaInfos = []; + snapshot.PriceAreaRefs = []; return; } - var mergedInfos = new List(); - var seenInfoIds = new HashSet(); - var areaNames = new List(); - + var refs = new List(); foreach (var areaId in areaIds) { - var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken); - var areaName = area?.Name; - if (!string.IsNullOrWhiteSpace(areaName)) - { - areaNames.Add(areaName.Trim()); - } - - var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken); - foreach (var info in infos) - { - if (!seenInfoIds.Add(info.Id)) - { - continue; - } - - mergedInfos.Add(KpiAreaSnapshotMapper.MapToSnapshotRule(info, areaName)); - } + refs.Add(await packService.EnsurePackAsync( + areaId, + KpiAreaPackService.AreaTypePrice, + publishUser, + description: "sales-product-price-publish", + cancellationToken)); } - snapshot.PriceAreaInfos = mergedInfos; - if (areaIds.Count == 1) + snapshot.PriceAreaRefs = refs; + if (refs.Count == 1) { - snapshot.PriceAreaId = areaIds[0]; - snapshot.PriceAreaName = areaNames.Count > 0 ? areaNames[0] : null; + snapshot.PriceAreaId = refs[0].AreaId; + snapshot.PriceAreaName = refs[0].AreaName; } else { - // 多表:不再落单一 PriceAreaId,名称拼接便于人工查看 snapshot.PriceAreaId = null; - snapshot.PriceAreaName = areaNames.Count > 0 ? string.Join("、", areaNames) : null; + snapshot.PriceAreaName = string.Join( + "、", + refs.Select(x => x.AreaName).Where(x => !string.IsNullOrWhiteSpace(x))); } } + /// + /// LIVE/模拟:不钉包,直接从当前库装载 Infos。 + /// + public static async Task ApplyLivePriceAreaInfosAsync( + SalesProductPricePublishSnapshotDto snapshot, + SalesProduct product, + IKpiAreaRepository kpiAreaRepository, + KpiAreaPackService packService, + CancellationToken cancellationToken = default) + { + var areaIds = await CollectResolvedPriceAreaIdsAsync( + product, + snapshot, + kpiAreaRepository, + cancellationToken); + await packService.LoadLiveInfosIntoPriceSnapshotAsync(snapshot, areaIds, cancellationToken); + } + /// /// 收集卖价明细(及产品 PriceAreaId)可解析到的全部价格分区表 Id,去重保序。 /// @@ -106,9 +113,6 @@ public static class PriceAreaSnapshotHelper return result; } - /// - /// 兼容旧调用:仅返回第一个可解析 AreaId(多表场景请用 CollectResolvedPriceAreaIdsAsync)。 - /// internal static async Task ResolvePriceAreaIdAsync( SalesProduct product, SalesProductPricePublishSnapshotDto snapshot, diff --git a/src/Oms.Application/Shared/RulesSnapshotHelper.cs b/src/Oms.Application/Shared/RulesSnapshotHelper.cs index 9643354..5d64b2b 100644 --- a/src/Oms.Application/Shared/RulesSnapshotHelper.cs +++ b/src/Oms.Application/Shared/RulesSnapshotHelper.cs @@ -1,17 +1,16 @@ using Oms.Application.Dtos.Orders; -using Oms.Application.Services.Orders; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; namespace Oms.Application.Shared; /// -/// 将黑名单/偏远/计重/进位规则固化进销售产品规则发布快照。 +/// 规则发布:黑名单/偏远钉分区包;计重/进位仍内嵌明细。 /// public static class RulesSnapshotHelper { /// - /// 按产品配置加载 KPI 分区、计重规则、进位区间写入规则快照。 + /// 发布路径:黑名单/偏远 Ensure 包写 Ref;计重/进位写 Infos。 /// public static async Task ApplyRulesToSnapshotAsync( SalesProductRulesPublishSnapshotDto snapshot, @@ -19,29 +18,37 @@ public static class RulesSnapshotHelper IKpiAreaRepository kpiAreaRepository, IWeightRuleRepository weightRuleRepository, IWeightRoundingRepository weightRoundingRepository, + KpiAreaPackService packService, + string? publishUser = null, CancellationToken cancellationToken = default) { - await ApplyKpiAreaToSnapshotAsync( + await ApplyKpiAreaPackRefAsync( snapshot, product.BlacklistAreaId, - kpiAreaRepository, - (s, areaId, areaName, infos) => + KpiAreaPackService.AreaTypeBlacklist, + packService, + publishUser, + (s, areaId, areaName, packRef) => { s.BlacklistAreaId = areaId; s.BlacklistAreaName = areaName; - s.BlacklistAreaInfos = infos; + s.BlacklistAreaRef = packRef; + s.BlacklistAreaInfos = []; }, cancellationToken); - await ApplyKpiAreaToSnapshotAsync( + await ApplyKpiAreaPackRefAsync( snapshot, product.RemoteAreaId, - kpiAreaRepository, - (s, areaId, areaName, infos) => + KpiAreaPackService.AreaTypeRemote, + packService, + publishUser, + (s, areaId, areaName, packRef) => { s.RemoteAreaId = areaId; s.RemoteAreaName = areaName; - s.RemoteAreaInfos = infos; + s.RemoteAreaRef = packRef; + s.RemoteAreaInfos = []; }, cancellationToken); @@ -49,6 +56,27 @@ public static class RulesSnapshotHelper await ApplyWeightRoundingToSnapshotAsync(snapshot, product, weightRoundingRepository, cancellationToken); } + /// + /// LIVE/模拟:黑名单/偏远从当前库装 Infos,不写 Ref。 + /// + public static async Task ApplyLiveRulesToSnapshotAsync( + SalesProductRulesPublishSnapshotDto snapshot, + SalesProduct product, + IKpiAreaRepository kpiAreaRepository, + IWeightRuleRepository weightRuleRepository, + IWeightRoundingRepository weightRoundingRepository, + KpiAreaPackService packService, + CancellationToken cancellationToken = default) + { + await packService.LoadLiveInfosIntoRulesSnapshotAsync( + snapshot, + product.BlacklistAreaId, + product.RemoteAreaId, + cancellationToken); + await ApplyWeightRuleToSnapshotAsync(snapshot, product, weightRuleRepository, cancellationToken); + await ApplyWeightRoundingToSnapshotAsync(snapshot, product, weightRoundingRepository, cancellationToken); + } + /// /// 从快照内嵌黑名单/偏远明细匹配地址(不查库)。 /// @@ -67,38 +95,34 @@ public static class RulesSnapshotHelper return GeoPartitionMatcher.IsHit(infos, countryCode, province, city, zipCode); } - /// - /// 将快照内嵌计重明细转为实体供计算器使用。 - /// public static List ToWeightRuleEntities(IReadOnlyList infos) => infos.Select(PublishSnapshotSlimmer.ToWeightRuleEntity).ToList(); - /// - /// 将快照内嵌进位区间转为实体供计算器使用。 - /// public static List ToWeightRoundingEntities(IReadOnlyList infos) => infos.Select(PublishSnapshotSlimmer.ToWeightRoundingEntity).ToList(); - private static async Task ApplyKpiAreaToSnapshotAsync( + private static async Task ApplyKpiAreaPackRefAsync( SalesProductRulesPublishSnapshotDto snapshot, int? areaId, - IKpiAreaRepository kpiAreaRepository, - Action> assign, + string areaType, + KpiAreaPackService packService, + string? publishUser, + Action assign, CancellationToken cancellationToken) { if (!areaId.HasValue || areaId.Value <= 0) { - assign(snapshot, null, null, []); + assign(snapshot, null, null, null); return; } - var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId.Value, cancellationToken); - var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value, cancellationToken); - assign( - snapshot, - areaId, - area?.Name, - infos.Select(info => KpiAreaSnapshotMapper.MapToSnapshotRule(info)).ToList()); + var packRef = await packService.EnsurePackAsync( + areaId.Value, + areaType, + publishUser, + description: "sales-product-rules-publish", + cancellationToken); + assign(snapshot, areaId, packRef.AreaName, packRef); } private static async Task ApplyWeightRuleToSnapshotAsync( diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaPublishRecord.cs b/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaPublishRecord.cs new file mode 100644 index 0000000..7d03858 --- /dev/null +++ b/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaPublishRecord.cs @@ -0,0 +1,33 @@ +using Oms.Domain.Shared; +using Oms.Domain.Shared.Enums; +using SqlSugar; + +namespace Oms.Domain.ChannelsAggregate.Entities; + +/// +/// KPI 分区发布包(价区/黑名单/偏远明细不可变快照)。 +/// +[SugarTable("t_kpi_area_publish_record")] +public class KpiAreaPublishRecord : OmsAuditedEntity +{ + public int AreaId { get; set; } + + /// price / blacklist / remote + public string AreaType { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public EsSnapshotStatus Status { get; set; } = EsSnapshotStatus.Active; + + public string ContentHash { get; set; } = string.Empty; + + [SugarColumn(ColumnDataType = "json", IsNullable = true)] + public string? Content { get; set; } + + public string PublishUser { get; set; } = string.Empty; + + public DateTime PublishTime { get; set; } + + [SugarColumn(IsNullable = true, Length = 512)] + public string? Description { get; set; } +} diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaPublishRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaPublishRepository.cs new file mode 100644 index 0000000..14f0836 --- /dev/null +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaPublishRepository.cs @@ -0,0 +1,31 @@ +using Oms.Domain.ChannelsAggregate.Entities; + +namespace Oms.Domain.ChannelsAggregate.Repositories; + +/// +/// KPI 分区发布包读写(可由 一并实现)。 +/// +public interface IKpiAreaPublishRepository +{ + Task FindPublishByAreaIdAndContentHashAsync( + int areaId, + string contentHash, + CancellationToken cancellationToken = default); + + Task GetPublishByAreaIdAndVersionAsync( + int areaId, + string version, + CancellationToken cancellationToken = default); + + Task> GetPublishHeadersByAreaIdAsync( + int areaId, + CancellationToken cancellationToken = default); + + Task GetPublishByIdAsync( + int id, + CancellationToken cancellationToken = default); + + Task InsertPublishRecordAsync( + KpiAreaPublishRecord record, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs index 5b3c230..57db116 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs @@ -3,7 +3,7 @@ using Oms.Domain.ChannelsAggregate.Entities; namespace Oms.Domain.ChannelsAggregate.Repositories; -public interface IKpiAreaRepository : IRepository +public interface IKpiAreaRepository : IRepository, IKpiAreaPublishRepository { Task GetByAppKeyTypeAndNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default); diff --git a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs index 53ba77e..1099272 100644 --- a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs @@ -2,6 +2,7 @@ using Fake.DependencyInjection; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.SqlSugarCore.Repositories; +using SqlSugar; namespace Oms.SqlSugarCore.Channels; @@ -64,4 +65,81 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit .OrderBy(x => x.Id) .ToListAsync(cancellationToken); } + + public async Task FindPublishByAreaIdAndContentHashAsync( + int areaId, + string contentHash, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var rows = await db.Queryable() + .Where(x => x.AreaId == areaId && x.ContentHash == contentHash) + .OrderBy(x => x.Id, OrderByType.Desc) + .Take(1) + .ToListAsync(cancellationToken); + return rows.FirstOrDefault(); + } + + public async Task GetPublishByAreaIdAndVersionAsync( + int areaId, + string version, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(version)) + { + return null; + } + + var db = await GetDbContextAsync(cancellationToken); + var rows = await db.Queryable() + .Where(x => x.AreaId == areaId && x.Version == version) + .OrderBy(x => x.Id, OrderByType.Desc) + .Take(1) + .ToListAsync(cancellationToken); + return rows.FirstOrDefault(); + } + + public async Task> GetPublishHeadersByAreaIdAsync( + int areaId, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.AreaId == areaId) + .OrderBy(x => x.PublishTime, OrderByType.Desc) + .Select(x => new KpiAreaPublishRecord + { + Id = x.Id, + AreaId = x.AreaId, + AreaType = x.AreaType, + Version = x.Version, + Status = x.Status, + ContentHash = x.ContentHash, + PublishUser = x.PublishUser, + PublishTime = x.PublishTime, + Description = x.Description, + CreateTime = x.CreateTime + }) + .ToListAsync(cancellationToken); + } + + public async Task GetPublishByIdAsync( + int id, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var rows = await db.Queryable() + .Where(x => x.Id == id) + .Take(1) + .ToListAsync(cancellationToken); + return rows.FirstOrDefault(); + } + + public async Task InsertPublishRecordAsync( + KpiAreaPublishRecord record, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Insertable(record).ExecuteReturnEntityAsync(); + } } diff --git a/src/Oms.Tests/RulesSnapshotHelperPublishTests.cs b/src/Oms.Tests/RulesSnapshotHelperPublishTests.cs index c27ad84..9b4a646 100644 --- a/src/Oms.Tests/RulesSnapshotHelperPublishTests.cs +++ b/src/Oms.Tests/RulesSnapshotHelperPublishTests.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using FluentAssertions; using Moq; using Oms.Application.Dtos.Orders; @@ -10,7 +11,7 @@ namespace Oms.Tests; public class RulesSnapshotHelperPublishTests { [Fact] - public async Task ApplyRulesToSnapshotAsync_ShouldEmbedBlacklistRemoteWeightAndRounding() + public async Task ApplyRulesToSnapshotAsync_ShouldPinBlacklistRemotePacksAndEmbedWeight() { var product = new SalesProduct { @@ -22,10 +23,16 @@ public class RulesSnapshotHelperPublishTests WeightRoundingId = 2 }; + var areas = new Dictionary + { + [10] = new() { Id = 10, Name = "黑名单区", AreaType = "blacklist" }, + [20] = new() { Id = 20, Name = "偏远区", AreaType = "remote" } + }; + var kpiMock = new Mock(); - kpiMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default)) - .ReturnsAsync((System.Linq.Expressions.Expression> _, CancellationToken _) => - new KpiArea { Id = 10, Name = "黑名单区" }); + kpiMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default)) + .ReturnsAsync((Expression> expr, CancellationToken _) => + areas.Values.FirstOrDefault(expr.Compile())); kpiMock.Setup(x => x.GetAllInfosByAreaIdAsync(10, default)) .ReturnsAsync( [ @@ -36,9 +43,17 @@ public class RulesSnapshotHelperPublishTests [ new KpiAreaInfo { Id = 2, AreaId = 20, CountryCode = "US", LeftPostcode = "06000", RightPostcode = "06999" } ]); + kpiMock.Setup(x => x.FindPublishByAreaIdAndContentHashAsync(It.IsAny(), It.IsAny(), default)) + .ReturnsAsync((KpiAreaPublishRecord?)null); + kpiMock.Setup(x => x.InsertPublishRecordAsync(It.IsAny(), default)) + .ReturnsAsync((KpiAreaPublishRecord r, CancellationToken _) => + { + r.Id = r.AreaId * 100; + return r; + }); var weightRuleMock = new Mock(); - weightRuleMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default)) + weightRuleMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default)) .ReturnsAsync(new WeightRule { Id = 1, Name = "标准计重" }); weightRuleMock.Setup(x => x.GetAllInfosByRuleIdAsync(1, default)) .ReturnsAsync( @@ -54,7 +69,7 @@ public class RulesSnapshotHelperPublishTests ]); var roundingMock = new Mock(); - roundingMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default)) + roundingMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default)) .ReturnsAsync(new WeightRounding { Id = 2, Name = "标准进位" }); roundingMock.Setup(x => x.GetAllInfosByRoundingIdAsync(2, default)) .ReturnsAsync( @@ -62,31 +77,30 @@ public class RulesSnapshotHelperPublishTests new WeightRoundingInfo { Id = 1, RoundingId = 2, StartValue = 0, EndValue = 20, Public = "0.5" } ]); + var packService = new KpiAreaPackService(kpiMock.Object); var snapshot = new SalesProductRulesPublishSnapshotDto(); await RulesSnapshotHelper.ApplyRulesToSnapshotAsync( snapshot, product, kpiMock.Object, weightRuleMock.Object, - roundingMock.Object); + roundingMock.Object, + packService, + "tester"); snapshot.BlacklistAreaId.Should().Be(10); - snapshot.BlacklistAreaInfos.Should().HaveCount(1); - snapshot.RemoteAreaId.Should().Be(20); - snapshot.RemoteAreaInfos.Should().HaveCount(1); - snapshot.WeightRuleId.Should().Be(1); + snapshot.BlacklistAreaRef.Should().NotBeNull(); + snapshot.BlacklistAreaRef!.PackVersion.Should().NotBeNullOrWhiteSpace(); + snapshot.BlacklistAreaInfos.Should().BeEmpty(); + snapshot.RemoteAreaRef.Should().NotBeNull(); + snapshot.RemoteAreaRef!.AreaId.Should().Be(20); snapshot.WeightRuleInfos.Should().HaveCount(1); - snapshot.WeightRoundingId.Should().Be(2); snapshot.WeightRoundingInfos.Should().HaveCount(1); - var json = OmsDocHelper.SerializeSnapshotJson(snapshot); - json.Should().Contain("PostcodeRange"); - json.Should().Contain("(90000,90010]"); - json.Should().Contain("WeightRuleInfos"); - json.Should().Contain("WeightRoundingInfos"); - json.Should().Contain("RemoteAreaInfos"); - json.Should().NotContain("CreateTime"); - json.Should().NotContain("UpdateTime"); - json.Should().NotContain("LeftPostcode"); + var json = OmsDocHelper.SerializeSnapshotJson(snapshot)!; + json.Should().Contain("blacklistAreaRef"); + json.Should().Contain("remoteAreaRef"); + json.Should().NotContain("blacklistAreaInfos"); + json.Should().NotContain("remoteAreaInfos"); } } -- 2.45.2 From 35156eedf4286e98fb2cf540d526fbbaaad94ee1 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 17:19:39 +0800 Subject: [PATCH 32/41] =?UTF-8?q?feat:=20=E6=B8=A0=E9=81=93=E4=B9=B0?= =?UTF-8?q?=E4=BB=B7/=E8=A7=84=E5=88=99=E5=8F=91=E5=B8=83=E9=92=89=20KPI?= =?UTF-8?q?=20=E5=88=86=E5=8C=BA=E5=8C=85=EF=BC=8C=E6=8E=92=E8=B4=A7?= =?UTF-8?q?=E5=BA=94=E4=BB=98=E6=8C=89=E6=B8=A0=E9=81=93=E4=BB=B7=E5=8C=BA?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各渠道异构价区与偏远不再复用产品卖价 zoneKeys;发布只存包引用,计费 hydrate 后硬失败。 Co-authored-by: Cursor --- .../ServiceChannelPublishSyncStatusOutput.cs | 22 ++ .../ServiceChannelRulesPublishSnapshotDto.cs | 16 ++ .../Dtos/Orders/ProductBillingSnapshotDtos.cs | 15 +- .../SalesProductService.BillingSimulate.cs | 96 +++++++-- .../Channels/ServiceChannelService.Helpers.cs | 65 ++++-- .../Channels/ServiceChannelService.cs | 59 ++++++ .../Orders/OrderService.ParcelBilling.cs | 198 ++++++++++++++++-- .../Orders/ParcelBillingCalculator.cs | 63 +++++- .../Shared/KpiAreaPackService.cs | 126 +++++++++++ .../Shared/PriceAreaSnapshotHelper.cs | 90 ++++++++ .../Shared/PublishSnapshotNormalizer.cs | 1 + 11 files changed, 676 insertions(+), 75 deletions(-) create mode 100644 src/Oms.Application/Dtos/Channels/ServiceChannelPublishSyncStatusOutput.cs diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelPublishSyncStatusOutput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelPublishSyncStatusOutput.cs new file mode 100644 index 0000000..cd94690 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelPublishSyncStatusOutput.cs @@ -0,0 +1,22 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 服务渠道发布同步态(轻量,不含快照正文)。 +/// +public class ServiceChannelPublishSyncStatusOutput +{ + /// 相对最新生效版本是否有待发布变更。 + public bool Pending { get; set; } + + /// 最新生效版本号;无则 null。 + public string? OnlineVersion { get; set; } + + /// 最新生效发布时间。 + public DateTime? OnlinePublishTime { get; set; } + + /// 待发布快照内容哈希(规范化后)。 + public string? PendingContentHash { get; set; } + + /// 线上快照内容哈希。 + public string? OnlineContentHash { get; set; } +} diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs index 553f95f..e192c5e 100644 --- a/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs @@ -1,3 +1,6 @@ +using System.Text.Json.Serialization; +using Oms.Application.Dtos.Orders; + namespace Oms.Application.Dtos.Channels; /// @@ -8,4 +11,17 @@ public class ServiceChannelRulesPublishSnapshotDto public ServiceChannelSaveInput? Channel { get; set; } public ServiceChannelDownstreamSnapshotDto? Downstream { get; set; } + + /// 偏远分区 ID。 + public int? RemoteAreaId { get; set; } + + /// 偏远分区名称。 + public string? RemoteAreaName { get; set; } + + /// 偏远分区包引用(规则发布时钉包)。 + public KpiAreaPackRefDto? RemoteAreaRef { get; set; } + + /// 运行时 hydrate / LIVE 用,不落发布 JSON。 + [JsonIgnore] + public List RemoteAreaInfos { get; set; } = []; } diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs index 1140307..0486a17 100644 --- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs +++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs @@ -129,12 +129,25 @@ public class SalesProductRulesPublishSnapshotDto } /// -/// 服务渠道买价发布快照。 +/// 服务渠道买价发布快照(渠道自有价区,与产品卖价分区独立)。 /// public class ServiceChannelPricePublishSnapshotDto { public int ServiceChannelId { get; set; } + /// 价格分区 ID(单表时冗余展示;以 PriceAreaRefs 为准)。 + public int? PriceAreaId { get; set; } + + /// 价格分区名称。 + public string? PriceAreaName { get; set; } + + /// 渠道价区包引用(买价发布时钉包)。 + public List PriceAreaRefs { get; set; } = []; + + /// 运行时 hydrate / LIVE 用,不落发布 JSON。 + [JsonIgnore] + public List PriceAreaInfos { get; set; } = []; + public List Prices { get; set; } = []; } diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs index b126256..3c6d5f9 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs @@ -185,13 +185,16 @@ public partial class SalesProductService payableFees = await TrySimulatePayableFeesAsync( product, rulesSnapshot, - zoneKeys, chargeableWeight, cargoAttribute, input.WarehouseId, warehouseName, isRemote, remoteZoneKeys, + countryCode, + input.Province, + input.City, + input.ZipCode, input.DeclaredSum, input.DeclaredCurrency, priceVersion, @@ -316,13 +319,16 @@ public partial class SalesProductService private async Task> TrySimulatePayableFeesAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto rulesSnapshot, - IReadOnlyList zoneKeys, decimal chargeableWeight, string cargoAttribute, int? warehouseId, string? warehouseName, - bool isRemote, - IReadOnlyList remoteZoneKeys, + bool productIsRemote, + IReadOnlyList productRemoteZoneKeys, + string countryCode, + string? province, + string? city, + string? zipCode, decimal? declaredSum, string? declaredCurrency, string priceVersion, @@ -344,7 +350,7 @@ public partial class SalesProductService WarehouseId = warehouseId, DeclaredSum = declaredSum, DeclaredCurrency = declaredCurrency, - IsRemote = isRemote ? (byte)1 : (byte)0 + IsRemote = productIsRemote ? (byte)1 : (byte)0 })).Passed; var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0); if (provider?.ServiceChannelId is not > 0) @@ -353,33 +359,81 @@ public partial class SalesProductService return []; } - var channelPrices = await BuildChannelPriceOutputsAsync(provider.ServiceChannelId.Value); - if (channelPrices.Count == 0) + var channelId = provider.ServiceChannelId.Value; + var channelPriceSnapshot = new ServiceChannelPricePublishSnapshotDto { - warnings.Add($"服务渠道 {provider.ServiceChannelId} 无买价,跳过应付估算"); + ServiceChannelId = channelId, + Prices = (await BuildChannelPriceOutputsAsync(channelId)) + .Select(PublishSnapshotSlimmer.SlimChannelPrice) + .ToList() + }; + if (channelPriceSnapshot.Prices.Count == 0) + { + warnings.Add($"服务渠道 {channelId} 无买价,跳过应付估算"); return []; } - var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == provider.ServiceChannelId.Value); - var payableRemoteZoneKeys = isRemote - ? ParcelBillingCalculator.BuildRemoteZoneKeys( - channel?.RemoteAreaId is > 0 ? channel.RemoteAreaId : null) - : Array.Empty(); - // 渠道未配偏远分区时回落产品偏远键,便于模拟偏远费行 - if (isRemote && payableRemoteZoneKeys.Count == 0) + await PriceAreaSnapshotHelper.ApplyLiveChannelPriceAreaInfosAsync( + channelPriceSnapshot, + KpiAreaRepository, + KpiAreaPackService); + var channelPrices = channelPriceSnapshot.Prices + .Select(PublishSnapshotSlimmer.ToChannelPriceListOutput) + .ToList(); + + IReadOnlyList payableZoneKeys = Array.Empty(); + if (channelPriceSnapshot.PriceAreaInfos is { Count: > 0 }) { - payableRemoteZoneKeys = remoteZoneKeys; + var resolved = ParcelBillingCalculator.TryResolveZoneKeysFromChannelPriceSnapshot( + channelPriceSnapshot, + countryCode, + province, + city, + zipCode); + if (resolved == null || resolved.Count == 0) + { + warnings.Add($"服务渠道 {channelId} 买价分区未匹配,跳过应付估算"); + return []; + } + + payableZoneKeys = resolved; + } + + var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId); + var channelRemoteAreaId = channel?.RemoteAreaId; + var channelIsRemote = productIsRemote; + if (channelRemoteAreaId is > 0) + { + var channelRules = new ServiceChannelRulesPublishSnapshotDto(); + await KpiAreaPackService.LoadLiveRemoteIntoChannelRulesSnapshotAsync( + channelRules, + channelRemoteAreaId); + channelIsRemote = GeoPartitionMatcher.IsHit( + channelRules.RemoteAreaInfos, + countryCode, + province, + city, + zipCode); + } + + var payableRemoteZoneKeys = channelIsRemote + ? ParcelBillingCalculator.BuildRemoteZoneKeys( + channelRemoteAreaId is > 0 ? channelRemoteAreaId : null) + : Array.Empty(); + if (channelIsRemote && payableRemoteZoneKeys.Count == 0) + { + payableRemoteZoneKeys = productRemoteZoneKeys; } var payableContext = OrderRuleEvaluationContext.FromOrderOnly(new Order { ProductCode = product.ProductCode, - ConsigneeCountry = null, + ConsigneeCountry = countryCode, CargoAttribute = cargoAttribute, WarehouseId = warehouseId, DeclaredSum = declaredSum, DeclaredCurrency = declaredCurrency, - IsRemote = isRemote ? (byte)1 : (byte)0 + IsRemote = channelIsRemote ? (byte)1 : (byte)0 }); var payable = ParcelBillingCalculator.CalculatePayableFees( @@ -387,7 +441,7 @@ public partial class SalesProductService priceVersion, priceEffectiveTime, chargeableWeight, - zoneKeys, + payableZoneKeys, cargoAttribute, OrderFeeChargingStageCodes.Forecast, warehouseId, @@ -399,11 +453,11 @@ public partial class SalesProductService if (payable.Count == 0) { - warnings.Add($"服务渠道 {provider.ServiceChannelId} 买价未匹配,跳过应付估算"); + warnings.Add($"服务渠道 {channelId} 买价未匹配,跳过应付估算"); return []; } - warnings.Add($"应付按排货服务渠道 {provider.ServiceChannelId} 估算(首个可用渠道)"); + warnings.Add($"应付按排货服务渠道 {channelId} 估算(首个可用渠道,渠道价区)"); return payable.Select(x => new SalesProductBillingSimulateFeeOutput { CostItem = x.CostItem, diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index 1317ee5..e3c2f22 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -243,23 +243,29 @@ public partial class ServiceChannelService } /// - /// 组装买价发布快照(不落库),与产品卖价 §5.3.2 对称。 + /// 组装买价发布快照(不落库):钉渠道自有价区包。 /// - private async Task BuildPricePublishSnapshotAsync(int serviceChannelId) + private async Task BuildPricePublishSnapshotAsync(int serviceChannelId) { - return new ServiceChannelPricePublishSnapshot + var snapshot = new ServiceChannelPricePublishSnapshotDto { ServiceChannelId = serviceChannelId, Prices = (await GetPriceOutputsAsync(serviceChannelId)) .Select(PublishSnapshotSlimmer.SlimChannelPrice) .ToList() }; + await PriceAreaSnapshotHelper.ApplyChannelPriceAreaToSnapshotAsync( + snapshot, + KpiAreaRepository, + KpiAreaPackService, + ResolvePublishUser()); + return snapshot; } /// - /// 组装渠道规则发布快照(不落库)。 + /// 组装渠道规则发布快照(不落库):钉偏远分区包。 /// - private async Task BuildRulesPublishSnapshotAsync(int serviceChannelId) + private async Task BuildRulesPublishSnapshotAsync(int serviceChannelId) { var entity = await GetEntityOrThrowAsync(serviceChannelId); ServiceChannelDownstreamSnapshotDto? downstream = null; @@ -272,13 +278,39 @@ public partial class ServiceChannelService } } - return new ServiceChannelRulesPublishSnapshot + var snapshot = new ServiceChannelRulesPublishSnapshotDto { Channel = MapChannelToSaveInput(entity), - Downstream = downstream + Downstream = downstream, + RemoteAreaInfos = [] }; + + if (entity.RemoteAreaId is > 0) + { + var packRef = await KpiAreaPackService.EnsurePackAsync( + entity.RemoteAreaId.Value, + KpiAreaPackService.AreaTypeRemote, + ResolvePublishUser(), + description: "service-channel-rules-publish"); + snapshot.RemoteAreaId = entity.RemoteAreaId; + snapshot.RemoteAreaName = packRef.AreaName; + snapshot.RemoteAreaRef = packRef; + } + else + { + snapshot.RemoteAreaId = null; + snapshot.RemoteAreaName = null; + snapshot.RemoteAreaRef = null; + } + + return snapshot; } + private string ResolvePublishUser() => + string.IsNullOrWhiteSpace(CurrentUser.UserName) + ? CurrentUser.GetId().ToString() + : CurrentUser.UserName!; + private static ServiceChannelSaveInput MapChannelToSaveInput(ServiceChannel entity) => new() { @@ -334,7 +366,7 @@ public partial class ServiceChannelService if (operatorType == PriceOperatorType) { - var snapshot = OmsDocHelper.DeserializeJson(record.Content); + var snapshot = OmsDocHelper.DeserializeChannelPriceSnapshot(record.Content); if (snapshot == null) { throw new Fake.Application.BusinessException("发布快照无效"); @@ -349,8 +381,8 @@ public partial class ServiceChannelService return; } - var rulesSnapshot = OmsDocHelper.DeserializeJson(record.Content); - if (rulesSnapshot == null) + var rulesSnapshot = OmsDocHelper.DeserializeJson(record.Content); + if (rulesSnapshot?.Channel == null) { throw new Fake.Application.BusinessException("发布快照无效"); } @@ -658,17 +690,4 @@ public partial class ServiceChannelService }; } - private sealed class ServiceChannelPricePublishSnapshot - { - public int ServiceChannelId { get; set; } - - public List Prices { get; set; } = []; - } - - private sealed class ServiceChannelRulesPublishSnapshot - { - public ServiceChannelSaveInput Channel { get; set; } = new(); - - public ServiceChannelDownstreamSnapshotDto? Downstream { get; set; } - } } diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index a3b535d..ba4c2e5 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -20,6 +20,8 @@ public partial class ServiceChannelService : ApplicationService private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService(); private IServiceChannelGroupRepository ServiceChannelGroupRepository => LazyServiceProvider.GetRequiredService(); + private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService(); + private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService(); private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher => LazyServiceProvider.GetRequiredService(); private OmsRbacUserDisplayNameResolver UserDisplayNameResolver => @@ -400,6 +402,63 @@ public partial class ServiceChannelService : ApplicationService return OmsDocHelper.ToSnapshotApiContent(snapshot)!; } + /// + /// 发布同步态(轻量):对比待发布与最新生效快照,不回传正文。与产品卖价对称。 + /// + [HttpGet("/channel/service-channel/publish/sync-status")] + public async Task GetPublishSyncStatusAsync( + [FromQuery] int serviceChannelId, + [FromQuery] string operatorType) + { + var normalized = NormalizeOperatorType(operatorType); + await GetEntityOrThrowAsync(serviceChannelId); + + object pendingSnapshot = normalized == PriceOperatorType + ? await BuildPricePublishSnapshotAsync(serviceChannelId) + : await BuildRulesPublishSnapshotAsync(serviceChannelId); + var pendingJson = OmsDocHelper.SerializeSnapshotJson(pendingSnapshot) ?? string.Empty; + var pendingHash = ComputeContentHash(pendingJson); + + var onlineRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync( + serviceChannelId, + normalized, + DateTime.Now); + if (onlineRecord == null) + { + return new ServiceChannelPublishSyncStatusOutput + { + Pending = true, + OnlineVersion = null, + OnlinePublishTime = null, + PendingContentHash = pendingHash, + OnlineContentHash = null + }; + } + + var onlineJson = onlineRecord.Content ?? string.Empty; + object? onlineNormalized = normalized == PriceOperatorType + ? OmsDocHelper.DeserializeChannelPriceSnapshot(onlineJson) + : OmsDocHelper.DeserializeJson(onlineJson); + var onlineCanon = OmsDocHelper.SerializeSnapshotJson(onlineNormalized) ?? string.Empty; + var onlineHash = ComputeContentHash(onlineCanon); + + return new ServiceChannelPublishSyncStatusOutput + { + Pending = !string.Equals(pendingHash, onlineHash, StringComparison.Ordinal), + OnlineVersion = onlineRecord.Version, + OnlinePublishTime = onlineRecord.PublishTime, + PendingContentHash = pendingHash, + OnlineContentHash = onlineHash + }; + } + + private static string ComputeContentHash(string content) + { + var hash = global::System.Security.Cryptography.SHA256.HashData( + global::System.Text.Encoding.UTF8.GetBytes(content)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + /// /// 查询服务渠道发布规则列表。 /// diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 659aed6..2fd317d 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -1057,8 +1057,9 @@ public partial class OrderService continue; } - var channelIsRemote = await IsChannelRemoteHitAsync( - channel, + var channelRulesBinding = await ResolveChannelRulesBindingAsync(channel, bindTime); + var channelIsRemote = IsChannelRemoteHit( + channelRulesBinding, order.ConsigneeCountry ?? string.Empty, recipient?.Province, recipient?.City, @@ -1068,15 +1069,38 @@ public partial class OrderService channelIsRemote ? (byte)1 : (byte)0); var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime); + var channelZoneKeys = ResolveChannelZoneKeysOrSkip( + channelPriceBinding, + order.ConsigneeCountry ?? string.Empty, + recipient?.Province, + recipient?.City, + recipient?.ZipCode, + out var channelZoneMissReason); + if (channelZoneKeys == null) + { + skipped.Add(new OrderAllocationSkippedOutput + { + ServiceChannelId = channel.Id, + ChannelCode = channel.ChannelCode, + ChannelName = channel.ChannelName, + Stage = AllocationResolver.SkipStageBuyPrice, + MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition), + Reason = channelZoneMissReason + ?? $"渠道买价分区未匹配(版本 {channelPriceBinding.PriceVersion})" + }); + continue; + } + var channelRemoteZoneKeys = channelIsRemote - ? ParcelBillingCalculator.BuildRemoteZoneKeys(channel.RemoteAreaId) + ? ParcelBillingCalculator.BuildRemoteZoneKeys( + channelRulesBinding.RemoteAreaId ?? channel.RemoteAreaId) : Array.Empty(); var previewFees = ParcelBillingCalculator.CalculatePayableFees( channelPriceBinding.Prices, channelPriceBinding.PriceVersion, channelPriceBinding.PriceEffectiveTime, chargeableWeight, - zoneKeys, + channelZoneKeys, cargoAttribute, chargingStage, order.WarehouseId, @@ -1090,7 +1114,7 @@ public partial class OrderService var miss = ParcelBillingCalculator.ExplainPayablePriceMiss( channelPriceBinding.Prices, chargeableWeight, - zoneKeys, + channelZoneKeys, cargoAttribute, order.WarehouseId, warehouseName, @@ -1369,6 +1393,15 @@ public partial class OrderService public string PriceVersion { get; init; } = string.Empty; public DateTime PriceEffectiveTime { get; init; } + + public ServiceChannelPricePublishSnapshotDto Snapshot { get; init; } = new(); + } + + private sealed class ChannelRulesBinding + { + public int? RemoteAreaId { get; init; } + + public ServiceChannelRulesPublishSnapshotDto Snapshot { get; init; } = new(); } private async Task ResolveChannelPriceBindingAsync(int serviceChannelId, DateTime bindTime) @@ -1382,23 +1415,157 @@ public partial class OrderService var snapshot = OmsDocHelper.DeserializeChannelPriceSnapshot(priceRecord.Content); if (snapshot?.Prices.Count > 0) { + await KpiAreaPackService.HydrateChannelPriceSnapshotAsync(snapshot); return new ChannelPriceBinding { Prices = snapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(), PriceVersion = priceRecord.Version, - PriceEffectiveTime = priceRecord.PublishTime + PriceEffectiveTime = priceRecord.PublishTime, + Snapshot = snapshot }; } } + var liveSnapshot = await BuildLiveChannelPriceSnapshotAsync(serviceChannelId); return new ChannelPriceBinding { - Prices = await BuildChannelPriceOutputsAsync(serviceChannelId), + Prices = liveSnapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(), PriceVersion = $"LIVE-{serviceChannelId}", - PriceEffectiveTime = bindTime + PriceEffectiveTime = bindTime, + Snapshot = liveSnapshot }; } + private async Task ResolveChannelRulesBindingAsync( + ServiceChannel channel, + DateTime bindTime) + { + var rulesRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync( + channel.Id, + RulesOperatorType, + bindTime); + if (rulesRecord?.Content != null) + { + var snapshot = OmsDocHelper.DeserializeJson(rulesRecord.Content); + if (snapshot != null) + { + await KpiAreaPackService.HydrateChannelRulesSnapshotAsync(snapshot); + return new ChannelRulesBinding + { + RemoteAreaId = snapshot.RemoteAreaRef?.AreaId + ?? snapshot.RemoteAreaId + ?? snapshot.Channel?.RemoteAreaId + ?? channel.RemoteAreaId, + Snapshot = snapshot + }; + } + } + + var live = new ServiceChannelRulesPublishSnapshotDto + { + Channel = new ServiceChannelSaveInput + { + Id = channel.Id, + RemoteAreaId = channel.RemoteAreaId + }, + RemoteAreaInfos = [] + }; + await KpiAreaPackService.LoadLiveRemoteIntoChannelRulesSnapshotAsync( + live, + channel.RemoteAreaId); + return new ChannelRulesBinding + { + RemoteAreaId = channel.RemoteAreaId, + Snapshot = live + }; + } + + private async Task BuildLiveChannelPriceSnapshotAsync( + int serviceChannelId) + { + var snapshot = new ServiceChannelPricePublishSnapshotDto + { + ServiceChannelId = serviceChannelId, + Prices = (await BuildChannelPriceOutputsAsync(serviceChannelId)) + .Select(PublishSnapshotSlimmer.SlimChannelPrice) + .ToList() + }; + await PriceAreaSnapshotHelper.ApplyLiveChannelPriceAreaInfosAsync( + snapshot, + KpiAreaRepository, + KpiAreaPackService); + return snapshot; + } + + /// + /// 按渠道买价价区包解析 zoneKeys;有价区配置但未命中时返回 null(跳过该渠道)。 + /// 无价区配置时返回空列表(不回落产品卖价分区)。 + /// + private static IReadOnlyList? ResolveChannelZoneKeysOrSkip( + ChannelPriceBinding binding, + string countryCode, + string? province, + string? city, + string? zipCode, + out string? missReason) + { + missReason = null; + var snapshot = binding.Snapshot; + if (snapshot.PriceAreaRefs is { Count: > 0 } + && snapshot.PriceAreaInfos is not { Count: > 0 }) + { + missReason = + $"渠道价格分区包未加载: refs={snapshot.PriceAreaRefs.Count} version={binding.PriceVersion}"; + return null; + } + + if (snapshot.PriceAreaInfos is not { Count: > 0 }) + { + return Array.Empty(); + } + + var keys = ParcelBillingCalculator.TryResolveZoneKeysFromChannelPriceSnapshot( + snapshot, + countryCode, + province, + city, + zipCode); + if (keys == null || keys.Count == 0) + { + missReason = $"渠道买价分区未匹配(版本 {binding.PriceVersion})"; + return null; + } + + return keys; + } + + private static bool IsChannelRemoteHit( + ChannelRulesBinding binding, + string countryCode, + string? province, + string? city, + string? zipCode) + { + var snapshot = binding.Snapshot; + if (snapshot.RemoteAreaRef != null && snapshot.RemoteAreaInfos is not { Count: > 0 }) + { + throw new BusinessException( + $"渠道偏远分区包未加载: areaId={snapshot.RemoteAreaRef.AreaId} version={snapshot.RemoteAreaRef.PackVersion}"); + } + + if (snapshot.RemoteAreaInfos is { Count: > 0 }) + { + return GeoPartitionMatcher.IsHit( + snapshot.RemoteAreaInfos, + countryCode, + province, + city, + zipCode); + } + + return false; + } + private async Task BuildLiveRulesSnapshotAsync(SalesProduct product) { var snapshot = new SalesProductRulesPublishSnapshotDto @@ -2970,19 +3137,4 @@ public partial class OrderService return OrderRuleEvaluationContext.FromOrderOnly(evalOrder); } - private async Task IsChannelRemoteHitAsync( - ServiceChannel channel, - string countryCode, - string? province, - string? city, - string? zipCode) - { - if (channel.RemoteAreaId is not > 0) - { - return false; - } - - var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(channel.RemoteAreaId.Value); - return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode); - } } diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 5c5762c..e73d78d 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -107,15 +107,53 @@ internal static class ParcelBillingCalculator string countryCode, string? province, string? city, + string? zipCode) => + TryResolveZoneKeysFromAreaInfos( + priceSnapshot?.PriceAreaInfos, + priceSnapshot?.PriceAreaId, + priceSnapshot?.PriceAreaName, + countryCode, + province, + city, + zipCode); + + /// + /// 从渠道买价快照分区规则解析分区键(不查库;与产品卖价独立)。 + /// + public static IReadOnlyList? TryResolveZoneKeysFromChannelPriceSnapshot( + ServiceChannelPricePublishSnapshotDto? priceSnapshot, + string countryCode, + string? province, + string? city, + string? zipCode) => + TryResolveZoneKeysFromAreaInfos( + priceSnapshot?.PriceAreaInfos, + priceSnapshot?.PriceAreaId, + priceSnapshot?.PriceAreaName, + countryCode, + province, + city, + zipCode); + + /// + /// 按分区规则列表匹配地址并生成 zone 键。 + /// + public static IReadOnlyList? TryResolveZoneKeysFromAreaInfos( + IReadOnlyList? priceAreaInfos, + int? priceAreaId, + string? priceAreaName, + string countryCode, + string? province, + string? city, string? zipCode) { - if (priceSnapshot?.PriceAreaInfos == null || priceSnapshot.PriceAreaInfos.Count == 0) + if (priceAreaInfos == null || priceAreaInfos.Count == 0) { return null; } var matched = GeoPartitionMatcher.MatchSnapshot( - priceSnapshot.PriceAreaInfos, + priceAreaInfos, countryCode, province, city, @@ -125,11 +163,10 @@ internal static class ParcelBillingCalculator return null; } - // 多价格分区表时优先用规则自身 AreaId,避免落到快照单一 PriceAreaId - var areaId = matched.AreaId > 0 ? matched.AreaId : priceSnapshot.PriceAreaId; + var areaId = matched.AreaId > 0 ? matched.AreaId : priceAreaId; var areaName = !string.IsNullOrWhiteSpace(matched.AreaName) ? matched.AreaName - : priceSnapshot.PriceAreaName; + : priceAreaName; var keys = BuildPriceZoneKeys(matched, areaName, areaId); return keys.Count > 0 ? keys : null; } @@ -476,10 +513,22 @@ internal static class ParcelBillingCalculator /// /// 收集卖价快照中所有启用价格项的明细分区键(去重)。 /// - public static IReadOnlyList CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot) + public static IReadOnlyList CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot) => + CollectDistinctDetailZoneKeys( + priceSnapshot.Prices.Select(p => (p.Status, p.Details))); + + /// + /// 收集渠道买价快照中所有启用价格项的明细分区键(去重)。 + /// + public static IReadOnlyList CollectDistinctDetailZoneKeys(ServiceChannelPricePublishSnapshotDto priceSnapshot) => + CollectDistinctDetailZoneKeys( + priceSnapshot.Prices.Select(p => (p.Status, p.Details))); + + private static IReadOnlyList CollectDistinctDetailZoneKeys( + IEnumerable<(byte Status, List? Details)> prices) { var zones = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var price in priceSnapshot.Prices.Where(x => EnableStatusRules.IsEnabled(x.Status))) + foreach (var price in prices.Where(x => EnableStatusRules.IsEnabled(x.Status))) { foreach (var detail in price.Details ?? []) { diff --git a/src/Oms.Application/Shared/KpiAreaPackService.cs b/src/Oms.Application/Shared/KpiAreaPackService.cs index 4eb0ac3..bdc8b4f 100644 --- a/src/Oms.Application/Shared/KpiAreaPackService.cs +++ b/src/Oms.Application/Shared/KpiAreaPackService.cs @@ -3,6 +3,7 @@ using System.Text; using Fake.Application; using Fake.DependencyInjection; using Microsoft.Extensions.Caching.Memory; +using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; @@ -254,6 +255,131 @@ public class KpiAreaPackService : IScopedDependency } } + /// + /// 渠道买价:Refs hydrate 到 Infos。 + /// + public async Task HydrateChannelPriceSnapshotAsync( + ServiceChannelPricePublishSnapshotDto snapshot, + CancellationToken cancellationToken = default) + { + if (snapshot.PriceAreaRefs is not { Count: > 0 }) + { + return; + } + + var merged = new List(); + var seen = new HashSet(); + foreach (var packRef in snapshot.PriceAreaRefs) + { + var rules = await GetRulesOrThrowAsync(packRef, cancellationToken); + foreach (var rule in rules) + { + if (seen.Add(rule.Id)) + { + merged.Add(rule); + } + } + } + + snapshot.PriceAreaInfos = merged; + if (snapshot.PriceAreaRefs.Count == 1) + { + snapshot.PriceAreaId = snapshot.PriceAreaRefs[0].AreaId; + snapshot.PriceAreaName = snapshot.PriceAreaRefs[0].AreaName; + } + else + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = string.Join( + "、", + snapshot.PriceAreaRefs + .Select(x => x.AreaName) + .Where(x => !string.IsNullOrWhiteSpace(x))); + } + } + + /// + /// 渠道规则:偏远 Ref hydrate。 + /// + public async Task HydrateChannelRulesSnapshotAsync( + ServiceChannelRulesPublishSnapshotDto snapshot, + CancellationToken cancellationToken = default) + { + if (snapshot.RemoteAreaRef != null) + { + snapshot.RemoteAreaInfos = await GetRulesOrThrowAsync(snapshot.RemoteAreaRef, cancellationToken); + snapshot.RemoteAreaId = snapshot.RemoteAreaRef.AreaId; + snapshot.RemoteAreaName = snapshot.RemoteAreaRef.AreaName; + } + } + + public async Task LoadLiveInfosIntoChannelPriceSnapshotAsync( + ServiceChannelPricePublishSnapshotDto snapshot, + IReadOnlyList areaIds, + CancellationToken cancellationToken = default) + { + var merged = new List(); + var seen = new HashSet(); + var names = new List(); + foreach (var areaId in areaIds.Where(id => id > 0).Distinct()) + { + var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken); + if (!string.IsNullOrWhiteSpace(area?.Name)) + { + names.Add(area.Name.Trim()); + } + + var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken); + foreach (var info in infos) + { + if (!seen.Add(info.Id)) + { + continue; + } + + merged.Add(KpiAreaSnapshotMapper.MapToSnapshotRule(info, area?.Name)); + } + } + + PublishSnapshotNormalizer.NormalizeKpiAreas(merged); + snapshot.PriceAreaInfos = merged; + snapshot.PriceAreaRefs = []; + if (areaIds.Count == 1) + { + snapshot.PriceAreaId = areaIds[0]; + snapshot.PriceAreaName = names.Count > 0 ? names[0] : null; + } + else + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = names.Count > 0 ? string.Join("、", names) : null; + } + } + + public async Task LoadLiveRemoteIntoChannelRulesSnapshotAsync( + ServiceChannelRulesPublishSnapshotDto snapshot, + int? remoteAreaId, + CancellationToken cancellationToken = default) + { + snapshot.RemoteAreaRef = null; + if (remoteAreaId is > 0) + { + var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == remoteAreaId.Value, cancellationToken); + var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(remoteAreaId.Value, cancellationToken); + var rules = infos.Select(i => KpiAreaSnapshotMapper.MapToSnapshotRule(i, area?.Name)).ToList(); + PublishSnapshotNormalizer.NormalizeKpiAreas(rules); + snapshot.RemoteAreaId = remoteAreaId; + snapshot.RemoteAreaName = area?.Name; + snapshot.RemoteAreaInfos = rules; + } + else + { + snapshot.RemoteAreaId = null; + snapshot.RemoteAreaName = null; + snapshot.RemoteAreaInfos = []; + } + } + public async Task LoadLiveInfosIntoRulesSnapshotAsync( SalesProductRulesPublishSnapshotDto snapshot, int? blacklistAreaId, diff --git a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs index fbcdcae..0f99dfa 100644 --- a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs +++ b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs @@ -127,6 +127,96 @@ public static class PriceAreaSnapshotHelper return ids.Count > 0 ? ids[0] : null; } + /// + /// 渠道买价发布:从明细推断价区表并钉包(与产品卖价分区独立)。 + /// + public static async Task ApplyChannelPriceAreaToSnapshotAsync( + ServiceChannelPricePublishSnapshotDto snapshot, + IKpiAreaRepository kpiAreaRepository, + KpiAreaPackService packService, + string? publishUser = null, + CancellationToken cancellationToken = default) + { + var areaIds = await CollectResolvedChannelPriceAreaIdsAsync( + snapshot, + kpiAreaRepository, + cancellationToken); + snapshot.PriceAreaInfos = []; + if (areaIds.Count == 0) + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = null; + snapshot.PriceAreaRefs = []; + return; + } + + var refs = new List(); + foreach (var areaId in areaIds) + { + refs.Add(await packService.EnsurePackAsync( + areaId, + KpiAreaPackService.AreaTypePrice, + publishUser, + description: "service-channel-price-publish", + cancellationToken)); + } + + snapshot.PriceAreaRefs = refs; + if (refs.Count == 1) + { + snapshot.PriceAreaId = refs[0].AreaId; + snapshot.PriceAreaName = refs[0].AreaName; + } + else + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = string.Join( + "、", + refs.Select(x => x.AreaName).Where(x => !string.IsNullOrWhiteSpace(x))); + } + } + + /// + /// 渠道买价 LIVE:不钉包,从当前库装 Infos。 + /// + public static async Task ApplyLiveChannelPriceAreaInfosAsync( + ServiceChannelPricePublishSnapshotDto snapshot, + IKpiAreaRepository kpiAreaRepository, + KpiAreaPackService packService, + CancellationToken cancellationToken = default) + { + var areaIds = await CollectResolvedChannelPriceAreaIdsAsync( + snapshot, + kpiAreaRepository, + cancellationToken); + await packService.LoadLiveInfosIntoChannelPriceSnapshotAsync(snapshot, areaIds, cancellationToken); + } + + internal static async Task> CollectResolvedChannelPriceAreaIdsAsync( + ServiceChannelPricePublishSnapshotDto snapshot, + IKpiAreaRepository kpiAreaRepository, + CancellationToken cancellationToken = default) + { + var result = new List(); + var seen = new HashSet(); + + void Add(int? areaId) + { + if (areaId is > 0 && seen.Add(areaId.Value)) + { + result.Add(areaId.Value); + } + } + + var zoneKeys = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshot); + foreach (var zoneKey in zoneKeys) + { + Add(await TryResolveZoneKeyToAreaIdAsync(zoneKey, kpiAreaRepository, cancellationToken)); + } + + return result; + } + private static async Task TryResolveZoneKeyToAreaIdAsync( string zoneKey, IKpiAreaRepository kpiAreaRepository, diff --git a/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs b/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs index 2eee1df..3f11120 100644 --- a/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs +++ b/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs @@ -38,6 +38,7 @@ public static class PublishSnapshotNormalizer return; } + NormalizeKpiAreas(snapshot.PriceAreaInfos); snapshot.Prices = SortChannelPriceItems(snapshot.Prices); } -- 2.45.2 From ae4e733a71c45bac8c81ff6ef47f0383200f6a72 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 17:34:31 +0800 Subject: [PATCH 33/41] =?UTF-8?q?feat:=20KPI=20=E5=88=86=E5=8C=BA=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=94=AF=E6=8C=81=E5=A2=9E=E9=87=8F/=E5=85=A8?= =?UTF-8?q?=E9=87=8F=EF=BC=8C=E5=88=A0=E9=99=A4=E5=88=86=E5=8C=BA=E7=BA=A7?= =?UTF-8?q?=E8=81=94=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/Channels/KpiAreaInfoImportInput.cs | 11 +++++++ .../Services/Channels/KpiAreaService.cs | 29 +++++++++++++++++-- .../Repositories/IKpiAreaRepository.cs | 2 ++ .../Channels/KpiAreaRepository.cs | 6 ++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs index a2e5388..a65be92 100644 --- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs +++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs @@ -12,8 +12,19 @@ public class KpiAreaInfoImportInput /// public int AreaId { get; set; } + /// + /// 导入模式:incremental=增量(跳过重复);full=全量(先清空本分区明细再导入)。 + /// + public string ImportMode { get; set; } = ImportModes.Incremental; + /// /// Excel 文件。 /// public IFormFile File { get; set; } = null!; + + public static class ImportModes + { + public const string Incremental = "incremental"; + public const string Full = "full"; + } } diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs index ff902a5..95f6061 100644 --- a/src/Oms.Application/Services/Channels/KpiAreaService.cs +++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs @@ -91,6 +91,7 @@ public class KpiAreaService : ApplicationService public async Task DeleteAsync(IdInput input) { var entity = await GetAreaOrThrowAsync(input.Id); + await KpiAreaRepository.DeleteInfosByAreaIdAsync(entity.Id); await KpiAreaRepository.DeleteAsync(entity); } @@ -203,6 +204,7 @@ public class KpiAreaService : ApplicationService } await GetAreaOrThrowAsync(input.AreaId); + var importMode = NormalizeImportMode(input.ImportMode); await using var stream = input.File.OpenReadStream(); var parsedRows = await KpiAreaInfoImportHelper.ParseImportRowsAsync(stream); @@ -218,9 +220,16 @@ public class KpiAreaService : ApplicationService .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var existingKeys = new HashSet(StringComparer.Ordinal); - foreach (var item in await KpiAreaRepository.GetAllInfosByAreaIdAsync(input.AreaId)) + if (importMode == KpiAreaInfoImportInput.ImportModes.Full) { - TryAddDuplicateKey(existingKeys, item); + await KpiAreaRepository.DeleteInfosByAreaIdAsync(input.AreaId); + } + else + { + foreach (var item in await KpiAreaRepository.GetAllInfosByAreaIdAsync(input.AreaId)) + { + TryAddDuplicateKey(existingKeys, item); + } } var output = new KpiAreaInfoImportOutput(); @@ -236,7 +245,9 @@ public class KpiAreaService : ApplicationService output.Failures.Add(new KpiAreaInfoImportFailRow { RowNumber = rowNumber, - Reason = "与已有规则重复,已跳过" + Reason = importMode == KpiAreaInfoImportInput.ImportModes.Full + ? "文件内规则重复,已跳过" + : "与已有规则重复,已跳过" }); continue; } @@ -258,6 +269,18 @@ public class KpiAreaService : ApplicationService return output; } + private static string NormalizeImportMode(string? mode) + { + if (string.Equals(mode, KpiAreaInfoImportInput.ImportModes.Full, StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "全量", StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "replace", StringComparison.OrdinalIgnoreCase)) + { + return KpiAreaInfoImportInput.ImportModes.Full; + } + + return KpiAreaInfoImportInput.ImportModes.Incremental; + } + private static Expression> BuildAreaPredicate(KpiAreaListInput input) { var appKey = input.AppKey?.Trim(); diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs index 57db116..6dce96b 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs @@ -21,5 +21,7 @@ public interface IKpiAreaRepository : IRepository, IKpiAreaPublishRepos Task DeleteInfoAsync(int id, CancellationToken cancellationToken = default); + Task DeleteInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default); + Task> GetAllInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs index 1099272..b06e0e4 100644 --- a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs @@ -57,6 +57,12 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit await db.Deleteable().Where(x => x.Id == id).ExecuteCommandAsync(cancellationToken); } + public async Task DeleteInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Deleteable().Where(x => x.AreaId == areaId).ExecuteCommandAsync(cancellationToken); + } + public async Task> GetAllInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); -- 2.45.2 From 9e5d6d20d5be09627143abcc15e0ab42045261e4 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 17:34:32 +0800 Subject: [PATCH 34/41] =?UTF-8?q?feat:=20=E4=BC=9A=E5=91=98=E6=A6=82?= =?UTF-8?q?=E8=A7=88=E8=A1=A5=E5=85=85=E7=AD=89=E7=BA=A7=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E4=B8=8E=E5=9B=BE=E6=A0=87=EF=BC=8C=E6=94=B6=E5=8F=91=E4=BB=B6?= =?UTF-8?q?=E4=BA=BA=E5=9B=BD=E5=AE=B6=E8=BF=94=E5=9B=9E=E4=BA=8C=E5=AD=97?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/Marketing/MemberOverviewOutput.cs | 4 ++ .../Dtos/Orders/OrderReceiverOutput.cs | 4 +- .../Dtos/Orders/OrderSenderOutput.cs | 4 +- .../Marketing/MarketingPortalMemberService.cs | 2 + .../Services/Orders/OrderService.Helpers.cs | 44 ++++++++++++++++--- 5 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs b/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs index b2b1cf3..90798de 100644 --- a/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs +++ b/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs @@ -4,8 +4,12 @@ public class MemberOverviewOutput { public int? MemberLevelId { get; set; } + public string? MemberLevelCode { get; set; } + public string? MemberLevelName { get; set; } + public string? IconUrl { get; set; } + public string? PriceTierKey { get; set; } public int TotalPoints { get; set; } diff --git a/src/Oms.Application/Dtos/Orders/OrderReceiverOutput.cs b/src/Oms.Application/Dtos/Orders/OrderReceiverOutput.cs index f5eb79a..c2c75fa 100644 --- a/src/Oms.Application/Dtos/Orders/OrderReceiverOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderReceiverOutput.cs @@ -31,9 +31,9 @@ public class OrderReceiverOutput public string? Email { get; set; } /// - /// 国家/地区代码。 + /// 国家/地区二字码。 /// - public int? CountryCode { get; set; } + public string? CountryCode { get; set; } /// /// 省份。 diff --git a/src/Oms.Application/Dtos/Orders/OrderSenderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderSenderOutput.cs index fe7fbc9..e9793d1 100644 --- a/src/Oms.Application/Dtos/Orders/OrderSenderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderSenderOutput.cs @@ -31,9 +31,9 @@ public class OrderSenderOutput public string? Email { get; set; } /// - /// 国家/地区代码。 + /// 国家/地区二字码。 /// - public int? CountryCode { get; set; } + public string? CountryCode { get; set; } /// /// 省份。 diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs index 15726fa..8bdb9c7 100644 --- a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs @@ -57,7 +57,9 @@ public class MarketingPortalMemberService : ApplicationService return new MemberOverviewOutput { MemberLevelId = currentLevel?.Id, + MemberLevelCode = currentLevel?.LevelCode, MemberLevelName = currentLevel?.LevelName, + IconUrl = currentLevel?.IconUrl, PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey), TotalPoints = account.TotalPoints, RollingPoints = rollingPoints, diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 9444025..c5d6e34 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -79,6 +79,20 @@ public partial class OrderService : await CountryRepository.GetListAsync(x => countryCodes.Contains(x.CountryCode)); var countryMap = countries.ToDictionary(x => x.CountryCode, StringComparer.OrdinalIgnoreCase); + var addressCountryIds = senders + .Select(x => x.Country) + .Concat(recipients.Select(x => x.Country)) + .Where(x => x.HasValue) + .Select(x => x!.Value) + .Distinct() + .ToArray(); + var addressCountries = addressCountryIds.Length == 0 + ? [] + : await CountryRepository.GetListAsync(x => addressCountryIds.Contains(x.Id)); + var countryIdCodeMap = addressCountries.ToDictionary( + x => x.Id, + x => x.CountryCode ?? string.Empty); + var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync( orders.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId })); @@ -252,8 +266,8 @@ public partial class OrderService CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.CreateUserId, auditUserNameMap), UpdateTime = OmsDocHelper.NullIfDefault(order.UpdateTime), UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.UpdateUserId, auditUserNameMap), - Sender = MapSender(sender), - Receiver = MapReceiver(recipient), + Sender = MapSender(sender, countryIdCodeMap), + Receiver = MapReceiver(recipient, countryIdCodeMap), Items = (items ?? []).Select(MapDeclaration).ToList(), WeightList = (orderWeights ?? []).Select(MapWeight).ToList(), FeeList = feeOutputs, @@ -705,7 +719,9 @@ public partial class OrderService : null; } - private static OrderSenderOutput? MapSender(OrderSender? sender) + private static OrderSenderOutput? MapSender( + OrderSender? sender, + IReadOnlyDictionary countryIdCodeMap) { return sender == null ? null @@ -716,7 +732,7 @@ public partial class OrderService Phone = sender.Phone, TelePhone = sender.TelePhone, Email = sender.Email, - CountryCode = sender.Country, + CountryCode = ResolveCountryCode(sender.Country, countryIdCodeMap), Province = sender.Province, City = sender.City, ZipCode = sender.ZipCode, @@ -727,7 +743,9 @@ public partial class OrderService }; } - private static OrderReceiverOutput? MapReceiver(OrderRecipient? recipient) + private static OrderReceiverOutput? MapReceiver( + OrderRecipient? recipient, + IReadOnlyDictionary countryIdCodeMap) { return recipient == null ? null @@ -738,7 +756,7 @@ public partial class OrderService Phone = recipient.Phone, TelePhone = recipient.TelePhone, Email = recipient.Email, - CountryCode = recipient.Country, + CountryCode = ResolveCountryCode(recipient.Country, countryIdCodeMap), Province = recipient.Province, City = recipient.City, StreetNumber = recipient.StreetNumber, @@ -755,6 +773,20 @@ public partial class OrderService }; } + private static string? ResolveCountryCode( + int? countryId, + IReadOnlyDictionary countryIdCodeMap) + { + if (!countryId.HasValue) + { + return null; + } + + return countryIdCodeMap.TryGetValue(countryId.Value, out var code) && !string.IsNullOrWhiteSpace(code) + ? code + : null; + } + private OrderItemOutput MapDeclaration(OrderDeclaration x) { return new OrderItemOutput -- 2.45.2 From a9f8373ec302cb716c80bd315429b1456a1c68d5 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 18:42:41 +0800 Subject: [PATCH 35/41] =?UTF-8?q?fix:=20=E5=88=86=E5=8C=BA=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E5=85=A8=E9=87=8F=E6=9F=A5=E8=AF=A2=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=20ORDER=20BY=EF=BC=8C=E9=81=BF=E5=85=8D=E6=9F=A5=E4=BB=B7=20so?= =?UTF-8?q?rt=5Fbuffer=20=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 大分区表全量 ORDER BY Id 会打满 MySQL sort_buffer;组包仍在内存排序,并补充 (AreaId, Id) 索引脚本。 Co-authored-by: Cursor --- sql/migrate_kpi_area_info_area_id_index.sql | 18 ++++++++++++++++++ .../Channels/KpiAreaRepository.cs | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 sql/migrate_kpi_area_info_area_id_index.sql diff --git a/sql/migrate_kpi_area_info_area_id_index.sql b/sql/migrate_kpi_area_info_area_id_index.sql new file mode 100644 index 0000000..da86588 --- /dev/null +++ b/sql/migrate_kpi_area_info_area_id_index.sql @@ -0,0 +1,18 @@ +-- 分区明细按 AreaId 全量/分页查询时避免 filesort 打满 sort_buffer(查价 LIVE 路径易踩) +SET @db := DATABASE(); + +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_kpi_area_info' + AND INDEX_NAME = 'idx_kpi_info_area_id' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE `t_kpi_area_info` ADD KEY `idx_kpi_info_area_id` (`AreaId`, `Id`)', + 'SELECT ''idx_kpi_info_area_id already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs index b06e0e4..c887260 100644 --- a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs @@ -21,9 +21,10 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit public async Task> GetInfosAsync(int areaId, int pageIndex, int pageSize, CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); + // 依赖 idx_kpi_info_area_id (AreaId, Id),避免大分区 filesort return await db.Queryable() .Where(x => x.AreaId == areaId) - .OrderBy(x => x.Id, SqlSugar.OrderByType.Desc) + .OrderBy(x => x.Id, OrderByType.Desc) .ToPageListAsync(pageIndex, pageSize, cancellationToken); } @@ -66,9 +67,10 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit public async Task> GetAllInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); + // 大分区表(如美区邮编)全量 ORDER BY 会打满 MySQL sort_buffer 导致 500; + // 需要稳定顺序的调用方在内存中排序(如 EnsurePackAsync)。 return await db.Queryable() .Where(x => x.AreaId == areaId) - .OrderBy(x => x.Id) .ToListAsync(cancellationToken); } -- 2.45.2 From 1704c9f1e26ea70affd9e8ff47e7b36e4c18d836 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 18:59:45 +0800 Subject: [PATCH 36/41] =?UTF-8?q?fix:=20=E5=88=86=E5=8C=BA=E5=8C=85?= =?UTF-8?q?=E6=8C=89=20Id=20=E6=9F=A5=E8=AF=A2=E9=81=BF=E5=85=8D=E5=A4=A7?= =?UTF-8?q?=20JSON=20=E5=8F=82=E4=B8=8E=20ORDER=20BY=20filesort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bb8c0a9 后查价 hydrate 会 SELECT Content 并 ORDER BY,大分区包打满 sort_buffer;改为先取 Id 再按主键加载。 Co-authored-by: Cursor --- src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs index c887260..e50b197 100644 --- a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs @@ -80,12 +80,15 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); - var rows = await db.Queryable() + // Content 为大 JSON:禁止 SELECT * + ORDER BY(filesort 打满 sort_buffer) + var ids = await db.Queryable() .Where(x => x.AreaId == areaId && x.ContentHash == contentHash) .OrderBy(x => x.Id, OrderByType.Desc) + .Select(x => x.Id) .Take(1) .ToListAsync(cancellationToken); - return rows.FirstOrDefault(); + var id = ids.FirstOrDefault(); + return id <= 0 ? null : await GetPublishByIdAsync(id, cancellationToken); } public async Task GetPublishByAreaIdAndVersionAsync( @@ -99,12 +102,15 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit } var db = await GetDbContextAsync(cancellationToken); - var rows = await db.Queryable() + // 查价 hydrate 分区包会走这里;先只排 Id,再按主键取 Content + var ids = await db.Queryable() .Where(x => x.AreaId == areaId && x.Version == version) .OrderBy(x => x.Id, OrderByType.Desc) + .Select(x => x.Id) .Take(1) .ToListAsync(cancellationToken); - return rows.FirstOrDefault(); + var id = ids.FirstOrDefault(); + return id <= 0 ? null : await GetPublishByIdAsync(id, cancellationToken); } public async Task> GetPublishHeadersByAreaIdAsync( -- 2.45.2 From 73f334edc819ec0a3ab7a060893087a66848bf6c Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 19:13:26 +0800 Subject: [PATCH 37/41] =?UTF-8?q?fix:=20=E5=8D=96=E4=BB=B7=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=80=81=E5=AF=B9=E6=AF=94=E6=94=B9=E7=94=A8=20canoni?= =?UTF-8?q?cal=20JSON=EF=BC=8C=E9=81=BF=E5=85=8D=E8=AF=AF=E6=8A=A5?= =?UTF-8?q?=E5=BE=85=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 待发布与线上两侧统一规范化后再算哈希,消除排序与 FactorValuesJson 形态差异导致的假 diff。 Co-authored-by: Cursor --- .../Channels/SalesProductService.Helpers.cs | 7 ++++- .../Services/Channels/SalesProductService.cs | 18 ++++++++----- .../Channels/ServiceChannelService.cs | 16 ++++++----- src/Oms.Application/Shared/OmsDocHelper.cs | 27 +++++++++++++++++++ .../Shared/PublishSnapshotNormalizer.cs | 8 ++++++ 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 5ae3a5e..7cce30d 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -371,6 +371,11 @@ public partial class SalesProductService snapshot = await BuildRulesPublishSnapshotAsync(salesProductId); } + // Content 存 canonical 形态,与 sync-status 对比口径一致 + var content = operatorType == PriceOperatorType + ? OmsDocHelper.CanonicalizeSalesProductPriceSnapshotJson(snapshot) + : OmsDocHelper.CanonicalizeSalesProductRulesSnapshotJson(snapshot); + // Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。 await SalesProductRepository.InsertPublishRecordAsync(new SalesProductPublishRecord { @@ -382,7 +387,7 @@ public partial class SalesProductService PublishTime = DateTime.Now, Description = description, AiAnalysisReport = string.IsNullOrWhiteSpace(aiAnalysisReport) ? null : aiAnalysisReport.Trim(), - Content = OmsDocHelper.SerializeSnapshotJson(snapshot) + Content = content }); } diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index 35f2f94..f2a96ba 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -420,11 +420,15 @@ public partial class SalesProductService : ApplicationService var normalized = NormalizeOperatorType(operatorType); await GetProductOrThrowAsync(salesProductId); + // 两侧都走「序列化 → 反序列化规范化 → 再序列化」,避免刚发布仍误判待发布 + // (价格项排序、FactorValuesJson 的 Dictionary/JsonElement 形态差异等) object pendingSnapshot = normalized == PriceOperatorType ? await BuildPricePublishSnapshotAsync(salesProductId) : await BuildRulesPublishSnapshotAsync(salesProductId); - var pendingJson = OmsDocHelper.SerializeSnapshotJson(pendingSnapshot) ?? string.Empty; - var pendingHash = ComputeContentHash(pendingJson); + var pendingCanon = normalized == PriceOperatorType + ? OmsDocHelper.CanonicalizeSalesProductPriceSnapshotJson(pendingSnapshot) + : OmsDocHelper.CanonicalizeSalesProductRulesSnapshotJson(pendingSnapshot); + var pendingHash = ComputeContentHash(pendingCanon); var headers = await SalesProductRepository.GetAllPublishRecordHeadersAsync(salesProductId, normalized); var onlineHeader = headers @@ -446,11 +450,11 @@ public partial class SalesProductService : ApplicationService var onlineRecord = await SalesProductRepository.GetPublishRecordByIdAsync(onlineHeader.Id); var onlineJson = onlineRecord?.Content ?? string.Empty; - // 线上 Content 与待发布统一经反序列化再序列化,避免字段顺序噪声 - object? onlineNormalized = normalized == PriceOperatorType - ? OmsDocHelper.DeserializePriceSnapshot(onlineJson) - : OmsDocHelper.DeserializeRulesSnapshot(onlineJson); - var onlineCanon = OmsDocHelper.SerializeSnapshotJson(onlineNormalized) ?? string.Empty; + var onlineCanon = normalized == PriceOperatorType + ? OmsDocHelper.CanonicalizeSalesProductPriceSnapshotJson( + OmsDocHelper.DeserializePriceSnapshot(onlineJson)) + : OmsDocHelper.CanonicalizeSalesProductRulesSnapshotJson( + OmsDocHelper.DeserializeRulesSnapshot(onlineJson)); var onlineHash = ComputeContentHash(onlineCanon); return new SalesProductPublishSyncStatusOutput diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index ba4c2e5..d0f8e4d 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -416,8 +416,10 @@ public partial class ServiceChannelService : ApplicationService object pendingSnapshot = normalized == PriceOperatorType ? await BuildPricePublishSnapshotAsync(serviceChannelId) : await BuildRulesPublishSnapshotAsync(serviceChannelId); - var pendingJson = OmsDocHelper.SerializeSnapshotJson(pendingSnapshot) ?? string.Empty; - var pendingHash = ComputeContentHash(pendingJson); + var pendingCanon = normalized == PriceOperatorType + ? OmsDocHelper.CanonicalizeChannelPriceSnapshotJson(pendingSnapshot) + : OmsDocHelper.SerializeSnapshotJson(pendingSnapshot) ?? string.Empty; + var pendingHash = ComputeContentHash(pendingCanon); var onlineRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync( serviceChannelId, @@ -436,10 +438,12 @@ public partial class ServiceChannelService : ApplicationService } var onlineJson = onlineRecord.Content ?? string.Empty; - object? onlineNormalized = normalized == PriceOperatorType - ? OmsDocHelper.DeserializeChannelPriceSnapshot(onlineJson) - : OmsDocHelper.DeserializeJson(onlineJson); - var onlineCanon = OmsDocHelper.SerializeSnapshotJson(onlineNormalized) ?? string.Empty; + var onlineCanon = normalized == PriceOperatorType + ? OmsDocHelper.CanonicalizeChannelPriceSnapshotJson( + OmsDocHelper.DeserializeChannelPriceSnapshot(onlineJson)) + : OmsDocHelper.SerializeSnapshotJson( + OmsDocHelper.DeserializeJson(onlineJson)) + ?? string.Empty; var onlineHash = ComputeContentHash(onlineCanon); return new ServiceChannelPublishSyncStatusOutput diff --git a/src/Oms.Application/Shared/OmsDocHelper.cs b/src/Oms.Application/Shared/OmsDocHelper.cs index ddcd2a3..15d56d8 100644 --- a/src/Oms.Application/Shared/OmsDocHelper.cs +++ b/src/Oms.Application/Shared/OmsDocHelper.cs @@ -150,6 +150,33 @@ public static class OmsDocHelper return snapshot; } + /// + /// 同步态对比用:先序列化再反序列化规范化,消除 Dictionary/JsonElement、字段顺序等噪声。 + /// + public static string CanonicalizeSalesProductPriceSnapshotJson(object? snapshot) + { + var raw = SerializeSnapshotJson(snapshot) ?? string.Empty; + return SerializeSnapshotJson(DeserializePriceSnapshot(raw)) ?? string.Empty; + } + + /// + /// 同步态对比用:规则快照规范化 canonical JSON。 + /// + public static string CanonicalizeSalesProductRulesSnapshotJson(object? snapshot) + { + var raw = SerializeSnapshotJson(snapshot) ?? string.Empty; + return SerializeSnapshotJson(DeserializeRulesSnapshot(raw)) ?? string.Empty; + } + + /// + /// 同步态对比用:渠道买价快照规范化 canonical JSON。 + /// + public static string CanonicalizeChannelPriceSnapshotJson(object? snapshot) + { + var raw = SerializeSnapshotJson(snapshot) ?? string.Empty; + return SerializeSnapshotJson(DeserializeChannelPriceSnapshot(raw)) ?? string.Empty; + } + /// /// 解析渠道买价发布 Content,供列表/对比 API 使用(兼容旧快照审计字段)。 /// diff --git a/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs b/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs index 3f11120..e122993 100644 --- a/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs +++ b/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs @@ -28,6 +28,7 @@ public static class PublishSnapshotNormalizer } NormalizeKpiAreas(snapshot.PriceAreaInfos); + snapshot.PriceAreaRefs = SortPackRefs(snapshot.PriceAreaRefs); snapshot.Prices = SortSalesProductPriceItems(snapshot.Prices); } @@ -39,6 +40,7 @@ public static class PublishSnapshotNormalizer } NormalizeKpiAreas(snapshot.PriceAreaInfos); + snapshot.PriceAreaRefs = SortPackRefs(snapshot.PriceAreaRefs); snapshot.Prices = SortChannelPriceItems(snapshot.Prices); } @@ -92,6 +94,12 @@ public static class PublishSnapshotNormalizer } } + private static List SortPackRefs(List? refs) => + refs? + .OrderBy(x => x.AreaId) + .ThenBy(x => x.PackVersion, StringComparer.Ordinal) + .ToList() ?? []; + private static List SortSalesProductPriceItems( List? prices) => prices? -- 2.45.2 From c2ac20df0477e8c9f60451a5df691ee8b04eb3c6 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 19:14:45 +0800 Subject: [PATCH 38/41] =?UTF-8?q?fix:=20=E4=B9=B0=E4=BB=B7=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E8=90=BD=E5=BA=93=E4=B9=9F=E8=B5=B0=20canonical=20JSO?= =?UTF-8?q?N=EF=BC=8C=E4=B8=8E=E5=90=8C=E6=AD=A5=E6=80=81=E5=AF=B9?= =?UTF-8?q?=E6=AF=94=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Services/Channels/ServiceChannelService.Helpers.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index e3c2f22..825dde4 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -337,7 +337,11 @@ public partial class ServiceChannelService ? await BuildPricePublishSnapshotAsync(serviceChannelId) : await BuildRulesPublishSnapshotAsync(serviceChannelId); - // Content 中保存完整快照,回滚时直接从快照恢复。 + var content = operatorType == PriceOperatorType + ? OmsDocHelper.CanonicalizeChannelPriceSnapshotJson(snapshot) + : OmsDocHelper.SerializeSnapshotJson(snapshot); + + // Content 存 canonical 形态,与 sync-status 对比口径一致。 await ServiceChannelRepository.InsertPublishRecordAsync(new ServiceChannelPublishRecord { ServiceChannelId = serviceChannelId, @@ -347,7 +351,7 @@ public partial class ServiceChannelService PublishUser = string.IsNullOrWhiteSpace(CurrentUser.UserName) ? CurrentUser.GetId().ToString() : CurrentUser.UserName!, PublishTime = DateTime.Now, Description = description, - Content = OmsDocHelper.SerializeSnapshotJson(snapshot) + Content = content }); } -- 2.45.2 From 0449a4a3f53ed47cc52108dd0694d20b085d316b Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 19:21:17 +0800 Subject: [PATCH 39/41] =?UTF-8?q?fix:=20=E5=8F=91=E5=B8=83=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=80=81=E6=8C=89=20ContentHash=20=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=EF=BC=8C=E4=B8=94=E4=B8=8D=E5=86=8D=20Ensure?= =?UTF-8?q?=20=E5=86=99=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync-status 用 ResolvePackRef;哈希忽略 PackVersion/AreaName。修复卖价页发布后因价格行签名跳过刷新导致徽章卡在待发布。 Co-authored-by: Cursor --- .../Channels/SalesProductService.Helpers.cs | 19 ++++ .../Services/Channels/SalesProductService.cs | 13 ++- .../Channels/ServiceChannelService.Helpers.cs | 19 ++++ .../Channels/ServiceChannelService.cs | 6 +- .../Shared/KpiAreaPackService.cs | 92 +++++++++++++------ src/Oms.Application/Shared/OmsDocHelper.cs | 24 +++++ .../Shared/PriceAreaSnapshotHelper.cs | 78 ++++++++++++++++ .../Shared/PublishSnapshotNormalizer.cs | 55 +++++++++++ 8 files changed, 266 insertions(+), 40 deletions(-) diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 7cce30d..fff4f66 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -317,6 +317,25 @@ public partial class SalesProductService return priceSnapshot; } + /// + /// 同步态待发布快照:价区只解析哈希/已有包,不 Ensure 写库。 + /// + private async Task BuildPricePublishSnapshotForSyncAsync(int salesProductId) + { + var product = await GetProductOrThrowAsync(salesProductId); + var priceSnapshot = new SalesProductPricePublishSnapshot + { + SalesProductId = salesProductId, + Prices = (await GetPriceOutputsAsync(salesProductId)).Select(PublishSnapshotSlimmer.SlimPrice).ToList() + }; + await PriceAreaSnapshotHelper.ApplyPriceAreaRefsForSyncAsync( + priceSnapshot, + product, + KpiAreaRepository, + KpiAreaPackService); + return priceSnapshot; + } + private string ResolvePublishUser() => string.IsNullOrWhiteSpace(CurrentUser.UserName) ? CurrentUser.GetId().ToString() diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index f2a96ba..c89d3b4 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -420,14 +420,13 @@ public partial class SalesProductService : ApplicationService var normalized = NormalizeOperatorType(operatorType); await GetProductOrThrowAsync(salesProductId); - // 两侧都走「序列化 → 反序列化规范化 → 再序列化」,避免刚发布仍误判待发布 - // (价格项排序、FactorValuesJson 的 Dictionary/JsonElement 形态差异等) + // 待发布:同步态不 Ensure 分区包;对比时忽略 PackVersion/AreaName,只比价格项 + 分区 ContentHash object pendingSnapshot = normalized == PriceOperatorType - ? await BuildPricePublishSnapshotAsync(salesProductId) + ? await BuildPricePublishSnapshotForSyncAsync(salesProductId) : await BuildRulesPublishSnapshotAsync(salesProductId); var pendingCanon = normalized == PriceOperatorType - ? OmsDocHelper.CanonicalizeSalesProductPriceSnapshotJson(pendingSnapshot) - : OmsDocHelper.CanonicalizeSalesProductRulesSnapshotJson(pendingSnapshot); + ? OmsDocHelper.HashSalesProductPriceSnapshotForSync(pendingSnapshot) + : OmsDocHelper.HashSalesProductRulesSnapshotForSync(pendingSnapshot); var pendingHash = ComputeContentHash(pendingCanon); var headers = await SalesProductRepository.GetAllPublishRecordHeadersAsync(salesProductId, normalized); @@ -451,9 +450,9 @@ public partial class SalesProductService : ApplicationService var onlineRecord = await SalesProductRepository.GetPublishRecordByIdAsync(onlineHeader.Id); var onlineJson = onlineRecord?.Content ?? string.Empty; var onlineCanon = normalized == PriceOperatorType - ? OmsDocHelper.CanonicalizeSalesProductPriceSnapshotJson( + ? OmsDocHelper.HashSalesProductPriceSnapshotForSync( OmsDocHelper.DeserializePriceSnapshot(onlineJson)) - : OmsDocHelper.CanonicalizeSalesProductRulesSnapshotJson( + : OmsDocHelper.HashSalesProductRulesSnapshotForSync( OmsDocHelper.DeserializeRulesSnapshot(onlineJson)); var onlineHash = ComputeContentHash(onlineCanon); diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index 825dde4..9f29b18 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -262,6 +262,25 @@ public partial class ServiceChannelService return snapshot; } + /// + /// 买价同步态:不 Ensure 新分区包。 + /// + private async Task BuildPricePublishSnapshotForSyncAsync(int serviceChannelId) + { + var snapshot = new ServiceChannelPricePublishSnapshotDto + { + ServiceChannelId = serviceChannelId, + Prices = (await GetPriceOutputsAsync(serviceChannelId)) + .Select(PublishSnapshotSlimmer.SlimChannelPrice) + .ToList() + }; + await PriceAreaSnapshotHelper.ApplyChannelPriceAreaRefsForSyncAsync( + snapshot, + KpiAreaRepository, + KpiAreaPackService); + return snapshot; + } + /// /// 组装渠道规则发布快照(不落库):钉偏远分区包。 /// diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index d0f8e4d..526b607 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -414,10 +414,10 @@ public partial class ServiceChannelService : ApplicationService await GetEntityOrThrowAsync(serviceChannelId); object pendingSnapshot = normalized == PriceOperatorType - ? await BuildPricePublishSnapshotAsync(serviceChannelId) + ? await BuildPricePublishSnapshotForSyncAsync(serviceChannelId) : await BuildRulesPublishSnapshotAsync(serviceChannelId); var pendingCanon = normalized == PriceOperatorType - ? OmsDocHelper.CanonicalizeChannelPriceSnapshotJson(pendingSnapshot) + ? OmsDocHelper.HashChannelPriceSnapshotForSync(pendingSnapshot) : OmsDocHelper.SerializeSnapshotJson(pendingSnapshot) ?? string.Empty; var pendingHash = ComputeContentHash(pendingCanon); @@ -439,7 +439,7 @@ public partial class ServiceChannelService : ApplicationService var onlineJson = onlineRecord.Content ?? string.Empty; var onlineCanon = normalized == PriceOperatorType - ? OmsDocHelper.CanonicalizeChannelPriceSnapshotJson( + ? OmsDocHelper.HashChannelPriceSnapshotForSync( OmsDocHelper.DeserializeChannelPriceSnapshot(onlineJson)) : OmsDocHelper.SerializeSnapshotJson( OmsDocHelper.DeserializeJson(onlineJson)) diff --git a/src/Oms.Application/Shared/KpiAreaPackService.cs b/src/Oms.Application/Shared/KpiAreaPackService.cs index bdc8b4f..576fcbd 100644 --- a/src/Oms.Application/Shared/KpiAreaPackService.cs +++ b/src/Oms.Application/Shared/KpiAreaPackService.cs @@ -41,6 +41,67 @@ public class KpiAreaPackService : IScopedDependency string? publishUser = null, string? description = null, CancellationToken cancellationToken = default) + { + var prepared = await PreparePackContentAsync(areaId, cancellationToken); + var existing = await _kpiAreaRepository.FindPublishByAreaIdAndContentHashAsync( + areaId, + prepared.ContentHash, + cancellationToken); + if (existing != null) + { + return ToRef(existing, prepared.AreaName); + } + + var version = DateTime.Now.ToString("yyyyMMddHHmmssfff"); + var record = new KpiAreaPublishRecord + { + AreaId = areaId, + AreaType = string.IsNullOrWhiteSpace(areaType) ? prepared.AreaType : areaType.Trim(), + Version = version, + Status = EsSnapshotStatus.Active, + ContentHash = prepared.ContentHash, + Content = prepared.ContentJson, + PublishUser = string.IsNullOrWhiteSpace(publishUser) ? "system" : publishUser.Trim(), + PublishTime = DateTime.Now, + Description = description + }; + record = await _kpiAreaRepository.InsertPublishRecordAsync(record, cancellationToken); + PackCache.Set(CacheKey(areaId, record.Version), prepared.Rules, new MemoryCacheEntryOptions + { + Size = 1, + SlidingExpiration = TimeSpan.FromHours(6) + }); + return ToRef(record, prepared.AreaName); + } + + /// + /// 同步态专用:只解析当前库明细哈希并回填已有包 Version;不落新包(避免 GET sync-status 写库)。 + /// + public async Task ResolvePackRefForSyncAsync( + int areaId, + CancellationToken cancellationToken = default) + { + var prepared = await PreparePackContentAsync(areaId, cancellationToken); + var existing = await _kpiAreaRepository.FindPublishByAreaIdAndContentHashAsync( + areaId, + prepared.ContentHash, + cancellationToken); + if (existing != null) + { + return ToRef(existing, prepared.AreaName); + } + + return new KpiAreaPackRefDto + { + AreaId = areaId, + AreaName = prepared.AreaName, + PackVersion = string.Empty, + ContentHash = prepared.ContentHash + }; + } + + private async Task<(string AreaType, string? AreaName, string ContentJson, string ContentHash, List Rules)> + PreparePackContentAsync(int areaId, CancellationToken cancellationToken) { if (areaId <= 0) { @@ -57,36 +118,7 @@ public class KpiAreaPackService : IScopedDependency var contentJson = OmsDocHelper.SerializeSnapshotJson(rules) ?? "[]"; var contentHash = ComputeSha256Hex(contentJson); - - var existing = await _kpiAreaRepository.FindPublishByAreaIdAndContentHashAsync( - areaId, - contentHash, - cancellationToken); - if (existing != null) - { - return ToRef(existing, area.Name); - } - - var version = DateTime.Now.ToString("yyyyMMddHHmmssfff"); - var record = new KpiAreaPublishRecord - { - AreaId = areaId, - AreaType = string.IsNullOrWhiteSpace(areaType) ? area.AreaType : areaType.Trim(), - Version = version, - Status = EsSnapshotStatus.Active, - ContentHash = contentHash, - Content = contentJson, - PublishUser = string.IsNullOrWhiteSpace(publishUser) ? "system" : publishUser.Trim(), - PublishTime = DateTime.Now, - Description = description - }; - record = await _kpiAreaRepository.InsertPublishRecordAsync(record, cancellationToken); - PackCache.Set(CacheKey(areaId, record.Version), rules, new MemoryCacheEntryOptions - { - Size = 1, - SlidingExpiration = TimeSpan.FromHours(6) - }); - return ToRef(record, area.Name); + return (area.AreaType, area.Name, contentJson, contentHash, rules); } /// diff --git a/src/Oms.Application/Shared/OmsDocHelper.cs b/src/Oms.Application/Shared/OmsDocHelper.cs index 15d56d8..c36b6e9 100644 --- a/src/Oms.Application/Shared/OmsDocHelper.cs +++ b/src/Oms.Application/Shared/OmsDocHelper.cs @@ -177,6 +177,30 @@ public static class OmsDocHelper return SerializeSnapshotJson(DeserializeChannelPriceSnapshot(raw)) ?? string.Empty; } + /// + /// 发布同步态哈希:canonical + 去掉 PackVersion/AreaName 等钉包展示字段。 + /// + public static string HashSalesProductPriceSnapshotForSync(object? snapshot) + { + var dto = DeserializePriceSnapshot(SerializeSnapshotJson(snapshot)); + PublishSnapshotNormalizer.StripVolatilePackPinsForSyncCompare(dto); + return SerializeSnapshotJson(dto) ?? string.Empty; + } + + public static string HashSalesProductRulesSnapshotForSync(object? snapshot) + { + var dto = DeserializeRulesSnapshot(SerializeSnapshotJson(snapshot)); + PublishSnapshotNormalizer.StripVolatilePackPinsForSyncCompare(dto); + return SerializeSnapshotJson(dto) ?? string.Empty; + } + + public static string HashChannelPriceSnapshotForSync(object? snapshot) + { + var dto = DeserializeChannelPriceSnapshot(SerializeSnapshotJson(snapshot)); + PublishSnapshotNormalizer.StripVolatilePackPinsForSyncCompare(dto); + return SerializeSnapshotJson(dto) ?? string.Empty; + } + /// /// 解析渠道买价发布 Content,供列表/对比 API 使用(兼容旧快照审计字段)。 /// diff --git a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs index 0f99dfa..70535aa 100644 --- a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs +++ b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs @@ -46,6 +46,46 @@ public static class PriceAreaSnapshotHelper cancellationToken)); } + AssignPriceAreaRefs(snapshot, refs); + } + + /// + /// 同步态:解析价区 ContentHash / 已有包 Version,不 Ensure 新包。 + /// + public static async Task ApplyPriceAreaRefsForSyncAsync( + SalesProductPricePublishSnapshotDto snapshot, + SalesProduct product, + IKpiAreaRepository kpiAreaRepository, + KpiAreaPackService packService, + CancellationToken cancellationToken = default) + { + var areaIds = await CollectResolvedPriceAreaIdsAsync( + product, + snapshot, + kpiAreaRepository, + cancellationToken); + snapshot.PriceAreaInfos = []; + if (areaIds.Count == 0) + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = null; + snapshot.PriceAreaRefs = []; + return; + } + + var refs = new List(); + foreach (var areaId in areaIds) + { + refs.Add(await packService.ResolvePackRefForSyncAsync(areaId, cancellationToken)); + } + + AssignPriceAreaRefs(snapshot, refs); + } + + private static void AssignPriceAreaRefs( + SalesProductPricePublishSnapshotDto snapshot, + List refs) + { snapshot.PriceAreaRefs = refs; if (refs.Count == 1) { @@ -161,6 +201,44 @@ public static class PriceAreaSnapshotHelper cancellationToken)); } + AssignChannelPriceAreaRefs(snapshot, refs); + } + + /// + /// 渠道买价同步态:不 Ensure 新包。 + /// + public static async Task ApplyChannelPriceAreaRefsForSyncAsync( + ServiceChannelPricePublishSnapshotDto snapshot, + IKpiAreaRepository kpiAreaRepository, + KpiAreaPackService packService, + CancellationToken cancellationToken = default) + { + var areaIds = await CollectResolvedChannelPriceAreaIdsAsync( + snapshot, + kpiAreaRepository, + cancellationToken); + snapshot.PriceAreaInfos = []; + if (areaIds.Count == 0) + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = null; + snapshot.PriceAreaRefs = []; + return; + } + + var refs = new List(); + foreach (var areaId in areaIds) + { + refs.Add(await packService.ResolvePackRefForSyncAsync(areaId, cancellationToken)); + } + + AssignChannelPriceAreaRefs(snapshot, refs); + } + + private static void AssignChannelPriceAreaRefs( + ServiceChannelPricePublishSnapshotDto snapshot, + List refs) + { snapshot.PriceAreaRefs = refs; if (refs.Count == 1) { diff --git a/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs b/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs index e122993..d35cf12 100644 --- a/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs +++ b/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs @@ -44,6 +44,61 @@ public static class PublishSnapshotNormalizer snapshot.Prices = SortChannelPriceItems(snapshot.Prices); } + /// + /// 同步态对比:分区包只比 AreaId+ContentHash(PackVersion 是钉包戳,AreaName 是展示名)。 + /// + public static void StripVolatilePackPinsForSyncCompare(SalesProductPricePublishSnapshotDto? snapshot) + { + if (snapshot is null) + { + return; + } + + snapshot.PriceAreaName = null; + foreach (var packRef in snapshot.PriceAreaRefs ?? []) + { + StripPackPin(packRef); + } + } + + public static void StripVolatilePackPinsForSyncCompare(ServiceChannelPricePublishSnapshotDto? snapshot) + { + if (snapshot is null) + { + return; + } + + snapshot.PriceAreaName = null; + foreach (var packRef in snapshot.PriceAreaRefs ?? []) + { + StripPackPin(packRef); + } + } + + public static void StripVolatilePackPinsForSyncCompare(SalesProductRulesPublishSnapshotDto? snapshot) + { + if (snapshot is null) + { + return; + } + + snapshot.BlacklistAreaName = null; + snapshot.RemoteAreaName = null; + StripPackPin(snapshot.BlacklistAreaRef); + StripPackPin(snapshot.RemoteAreaRef); + } + + private static void StripPackPin(KpiAreaPackRefDto? packRef) + { + if (packRef is null) + { + return; + } + + packRef.PackVersion = string.Empty; + packRef.AreaName = null; + } + public static void NormalizeKpiAreas(List? areas) { if (areas is null) -- 2.45.2 From 7d14c0e5d9fe4c754aee66120a436cb86e233115 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 19:38:58 +0800 Subject: [PATCH 40/41] =?UTF-8?q?fix:=20sync-status=20=E5=8F=AA=E6=AF=94?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E9=85=8D=E7=BD=AE=E4=B8=8E=E7=94=9F=E6=95=88?= =?UTF-8?q?=20Content=EF=BC=8C=E5=89=8D=E7=AB=AF=E4=B8=8D=E5=8F=82?= =?UTF-8?q?=E4=B8=8E=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当前侧与发布同源组装但不 Ensure;两侧同一套规范化并忽略 PackVersion/AreaName。 Co-authored-by: Cursor --- .../Channels/SalesProductService.Helpers.cs | 23 +++++++ .../Services/Channels/SalesProductService.cs | 53 ++++++++------- .../Channels/ServiceChannelService.Helpers.cs | 40 ++++++++++++ .../Channels/ServiceChannelService.cs | 55 +++++++++++----- .../Shared/RulesSnapshotHelper.cs | 64 +++++++++++++++++-- 5 files changed, 184 insertions(+), 51 deletions(-) diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index fff4f66..d7bc155 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -297,6 +297,29 @@ public partial class SalesProductService return rulesSnapshot; } + /// + /// 同步态规则快照:分区不 Ensure,其余与发布组装一致。 + /// + private async Task BuildRulesPublishSnapshotForSyncAsync(int salesProductId) + { + var product = await GetProductOrThrowAsync(salesProductId); + var rulesSnapshot = new SalesProductRulesPublishSnapshot + { + Product = MapProductToRulesSaveInput(product), + Providers = (await GetAllocationCoreAsync(salesProductId)).Providers + .Select(PublishSnapshotSlimmer.SlimAllocationProvider) + .ToList() + }; + await RulesSnapshotHelper.ApplyRulesToSnapshotForSyncAsync( + rulesSnapshot, + product, + WeightRuleRepository, + WeightRoundingRepository, + KpiAreaPackService); + await AllocationProviderSnapshotEnricher.EnrichAsync(rulesSnapshot.Providers); + return rulesSnapshot; + } + /// /// 组装价格发布快照(不落库);价区钉分区包 Refs。 /// diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index c89d3b4..bba0e20 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -410,7 +410,7 @@ public partial class SalesProductService : ApplicationService } /// - /// 发布同步态(轻量):对比待发布与最新生效快照,不回传正文。 + /// 发布同步态:当前配置 vs 最新生效发布 Content。前端只展示 pending,不算差异。 /// [HttpGet("/channel/sales-product/publish/sync-status")] public async Task GetPublishSyncStatusAsync( @@ -420,48 +420,45 @@ public partial class SalesProductService : ApplicationService var normalized = NormalizeOperatorType(operatorType); await GetProductOrThrowAsync(salesProductId); - // 待发布:同步态不 Ensure 分区包;对比时忽略 PackVersion/AreaName,只比价格项 + 分区 ContentHash - object pendingSnapshot = normalized == PriceOperatorType + // 当前 = 与发布同源的快照组装(分区只解析哈希,不 Ensure 写库) + object currentSnapshot = normalized == PriceOperatorType ? await BuildPricePublishSnapshotForSyncAsync(salesProductId) - : await BuildRulesPublishSnapshotAsync(salesProductId); - var pendingCanon = normalized == PriceOperatorType - ? OmsDocHelper.HashSalesProductPriceSnapshotForSync(pendingSnapshot) - : OmsDocHelper.HashSalesProductRulesSnapshotForSync(pendingSnapshot); - var pendingHash = ComputeContentHash(pendingCanon); + : await BuildRulesPublishSnapshotForSyncAsync(salesProductId); + var currentHash = ComputeContentHash( + normalized == PriceOperatorType + ? OmsDocHelper.HashSalesProductPriceSnapshotForSync(currentSnapshot) + : OmsDocHelper.HashSalesProductRulesSnapshotForSync(currentSnapshot)); - var headers = await SalesProductRepository.GetAllPublishRecordHeadersAsync(salesProductId, normalized); - var onlineHeader = headers - .Where(x => x.Status == Domain.Shared.Enums.EsSnapshotStatus.Active) - .OrderByDescending(x => x.PublishTime) - .FirstOrDefault(); - - if (onlineHeader == null) + // 生效 = 最新 Active 发布记录 Content + var onlineRecord = await SalesProductRepository.GetEffectivePublishRecordAsync( + salesProductId, + normalized, + DateTime.Now); + if (onlineRecord == null || string.IsNullOrWhiteSpace(onlineRecord.Content)) { return new SalesProductPublishSyncStatusOutput { Pending = true, OnlineVersion = null, OnlinePublishTime = null, - PendingContentHash = pendingHash, + PendingContentHash = currentHash, OnlineContentHash = null }; } - var onlineRecord = await SalesProductRepository.GetPublishRecordByIdAsync(onlineHeader.Id); - var onlineJson = onlineRecord?.Content ?? string.Empty; - var onlineCanon = normalized == PriceOperatorType - ? OmsDocHelper.HashSalesProductPriceSnapshotForSync( - OmsDocHelper.DeserializePriceSnapshot(onlineJson)) - : OmsDocHelper.HashSalesProductRulesSnapshotForSync( - OmsDocHelper.DeserializeRulesSnapshot(onlineJson)); - var onlineHash = ComputeContentHash(onlineCanon); + var onlineHash = ComputeContentHash( + normalized == PriceOperatorType + ? OmsDocHelper.HashSalesProductPriceSnapshotForSync( + OmsDocHelper.DeserializePriceSnapshot(onlineRecord.Content)) + : OmsDocHelper.HashSalesProductRulesSnapshotForSync( + OmsDocHelper.DeserializeRulesSnapshot(onlineRecord.Content))); return new SalesProductPublishSyncStatusOutput { - Pending = !string.Equals(pendingHash, onlineHash, StringComparison.Ordinal), - OnlineVersion = onlineHeader.Version, - OnlinePublishTime = onlineHeader.PublishTime, - PendingContentHash = pendingHash, + Pending = !string.Equals(currentHash, onlineHash, StringComparison.Ordinal), + OnlineVersion = onlineRecord.Version, + OnlinePublishTime = onlineRecord.PublishTime, + PendingContentHash = currentHash, OnlineContentHash = onlineHash }; } diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index 9f29b18..b986819 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -325,6 +325,46 @@ public partial class ServiceChannelService return snapshot; } + /// + /// 渠道规则同步态:偏远分区不 Ensure。 + /// + private async Task BuildRulesPublishSnapshotForSyncAsync(int serviceChannelId) + { + var entity = await GetEntityOrThrowAsync(serviceChannelId); + ServiceChannelDownstreamSnapshotDto? downstream = null; + if (entity.GroupId is > 0) + { + var group = await ServiceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == entity.GroupId.Value); + if (group != null) + { + downstream = await AllocationProviderSnapshotEnricher.BuildDownstreamFromGroupAsync(group); + } + } + + var snapshot = new ServiceChannelRulesPublishSnapshotDto + { + Channel = MapChannelToSaveInput(entity), + Downstream = downstream, + RemoteAreaInfos = [] + }; + + if (entity.RemoteAreaId is > 0) + { + var packRef = await KpiAreaPackService.ResolvePackRefForSyncAsync(entity.RemoteAreaId.Value); + snapshot.RemoteAreaId = entity.RemoteAreaId; + snapshot.RemoteAreaName = packRef.AreaName; + snapshot.RemoteAreaRef = packRef; + } + else + { + snapshot.RemoteAreaId = null; + snapshot.RemoteAreaName = null; + snapshot.RemoteAreaRef = null; + } + + return snapshot; + } + private string ResolvePublishUser() => string.IsNullOrWhiteSpace(CurrentUser.UserName) ? CurrentUser.GetId().ToString() diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index 526b607..4e8336d 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -403,7 +403,7 @@ public partial class ServiceChannelService : ApplicationService } /// - /// 发布同步态(轻量):对比待发布与最新生效快照,不回传正文。与产品卖价对称。 + /// 发布同步态:当前配置 vs 最新生效发布 Content。前端只展示 pending。 /// [HttpGet("/channel/service-channel/publish/sync-status")] public async Task GetPublishSyncStatusAsync( @@ -413,49 +413,70 @@ public partial class ServiceChannelService : ApplicationService var normalized = NormalizeOperatorType(operatorType); await GetEntityOrThrowAsync(serviceChannelId); - object pendingSnapshot = normalized == PriceOperatorType - ? await BuildPricePublishSnapshotForSyncAsync(serviceChannelId) - : await BuildRulesPublishSnapshotAsync(serviceChannelId); - var pendingCanon = normalized == PriceOperatorType - ? OmsDocHelper.HashChannelPriceSnapshotForSync(pendingSnapshot) - : OmsDocHelper.SerializeSnapshotJson(pendingSnapshot) ?? string.Empty; - var pendingHash = ComputeContentHash(pendingCanon); + string currentCanon; + if (normalized == PriceOperatorType) + { + var current = await BuildPricePublishSnapshotForSyncAsync(serviceChannelId); + currentCanon = OmsDocHelper.HashChannelPriceSnapshotForSync(current); + } + else + { + var current = await BuildRulesPublishSnapshotForSyncAsync(serviceChannelId); + currentCanon = HashChannelRulesSnapshotForSync(current); + } + + var currentHash = ComputeContentHash(currentCanon); var onlineRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync( serviceChannelId, normalized, DateTime.Now); - if (onlineRecord == null) + if (onlineRecord == null || string.IsNullOrWhiteSpace(onlineRecord.Content)) { return new ServiceChannelPublishSyncStatusOutput { Pending = true, OnlineVersion = null, OnlinePublishTime = null, - PendingContentHash = pendingHash, + PendingContentHash = currentHash, OnlineContentHash = null }; } - var onlineJson = onlineRecord.Content ?? string.Empty; var onlineCanon = normalized == PriceOperatorType ? OmsDocHelper.HashChannelPriceSnapshotForSync( - OmsDocHelper.DeserializeChannelPriceSnapshot(onlineJson)) - : OmsDocHelper.SerializeSnapshotJson( - OmsDocHelper.DeserializeJson(onlineJson)) - ?? string.Empty; + OmsDocHelper.DeserializeChannelPriceSnapshot(onlineRecord.Content)) + : HashChannelRulesSnapshotForSync( + OmsDocHelper.DeserializeJson(onlineRecord.Content)); var onlineHash = ComputeContentHash(onlineCanon); return new ServiceChannelPublishSyncStatusOutput { - Pending = !string.Equals(pendingHash, onlineHash, StringComparison.Ordinal), + Pending = !string.Equals(currentHash, onlineHash, StringComparison.Ordinal), OnlineVersion = onlineRecord.Version, OnlinePublishTime = onlineRecord.PublishTime, - PendingContentHash = pendingHash, + PendingContentHash = currentHash, OnlineContentHash = onlineHash }; } + private static string HashChannelRulesSnapshotForSync(ServiceChannelRulesPublishSnapshotDto? snapshot) + { + if (snapshot == null) + { + return string.Empty; + } + + snapshot.RemoteAreaName = null; + if (snapshot.RemoteAreaRef != null) + { + snapshot.RemoteAreaRef.PackVersion = string.Empty; + snapshot.RemoteAreaRef.AreaName = null; + } + + return OmsDocHelper.SerializeSnapshotJson(snapshot) ?? string.Empty; + } + private static string ComputeContentHash(string content) { var hash = global::System.Security.Cryptography.SHA256.HashData( diff --git a/src/Oms.Application/Shared/RulesSnapshotHelper.cs b/src/Oms.Application/Shared/RulesSnapshotHelper.cs index 5d64b2b..62c5154 100644 --- a/src/Oms.Application/Shared/RulesSnapshotHelper.cs +++ b/src/Oms.Application/Shared/RulesSnapshotHelper.cs @@ -28,6 +28,7 @@ public static class RulesSnapshotHelper KpiAreaPackService.AreaTypeBlacklist, packService, publishUser, + ensurePack: true, (s, areaId, areaName, packRef) => { s.BlacklistAreaId = areaId; @@ -43,6 +44,54 @@ public static class RulesSnapshotHelper KpiAreaPackService.AreaTypeRemote, packService, publishUser, + ensurePack: true, + (s, areaId, areaName, packRef) => + { + s.RemoteAreaId = areaId; + s.RemoteAreaName = areaName; + s.RemoteAreaRef = packRef; + s.RemoteAreaInfos = []; + }, + cancellationToken); + + await ApplyWeightRuleToSnapshotAsync(snapshot, product, weightRuleRepository, cancellationToken); + await ApplyWeightRoundingToSnapshotAsync(snapshot, product, weightRoundingRepository, cancellationToken); + } + + /// + /// 同步态:黑名单/偏远只解析哈希/已有包,不 Ensure 写库。 + /// + public static async Task ApplyRulesToSnapshotForSyncAsync( + SalesProductRulesPublishSnapshotDto snapshot, + SalesProduct product, + IWeightRuleRepository weightRuleRepository, + IWeightRoundingRepository weightRoundingRepository, + KpiAreaPackService packService, + CancellationToken cancellationToken = default) + { + await ApplyKpiAreaPackRefAsync( + snapshot, + product.BlacklistAreaId, + KpiAreaPackService.AreaTypeBlacklist, + packService, + publishUser: null, + ensurePack: false, + (s, areaId, areaName, packRef) => + { + s.BlacklistAreaId = areaId; + s.BlacklistAreaName = areaName; + s.BlacklistAreaRef = packRef; + s.BlacklistAreaInfos = []; + }, + cancellationToken); + + await ApplyKpiAreaPackRefAsync( + snapshot, + product.RemoteAreaId, + KpiAreaPackService.AreaTypeRemote, + packService, + publishUser: null, + ensurePack: false, (s, areaId, areaName, packRef) => { s.RemoteAreaId = areaId; @@ -107,6 +156,7 @@ public static class RulesSnapshotHelper string areaType, KpiAreaPackService packService, string? publishUser, + bool ensurePack, Action assign, CancellationToken cancellationToken) { @@ -116,12 +166,14 @@ public static class RulesSnapshotHelper return; } - var packRef = await packService.EnsurePackAsync( - areaId.Value, - areaType, - publishUser, - description: "sales-product-rules-publish", - cancellationToken); + var packRef = ensurePack + ? await packService.EnsurePackAsync( + areaId.Value, + areaType, + publishUser, + description: "sales-product-rules-publish", + cancellationToken) + : await packService.ResolvePackRefForSyncAsync(areaId.Value, cancellationToken); assign(snapshot, areaId, packRef.AreaName, packRef); } -- 2.45.2 From 984799fcde4185565d5c4ff362c7f27985ce30db Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 19:52:04 +0800 Subject: [PATCH 41/41] =?UTF-8?q?fix:=20sync-status=20=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E4=BB=B7=E8=A1=A8=E6=98=8E=E7=BB=86=E6=95=B0=E5=AD=97=20JSON?= =?UTF-8?q?=20=E5=BD=A2=E6=80=81=E5=B7=AE=E5=BC=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LIVE 侧 DeserializeJsonObject 常把客户组价收到 long,历史 Content 里常见 133.0/0.0000;JS 结构相等但 SHA256 不等导致误报待发布。 Co-authored-by: Cursor --- src/Oms.Application/Shared/OmsDocHelper.cs | 130 +++++++++++++++++++-- src/Oms.Tests/OmsDocHelperTests.cs | 103 ++++++++++++++++ 2 files changed, 225 insertions(+), 8 deletions(-) diff --git a/src/Oms.Application/Shared/OmsDocHelper.cs b/src/Oms.Application/Shared/OmsDocHelper.cs index c36b6e9..2641b37 100644 --- a/src/Oms.Application/Shared/OmsDocHelper.cs +++ b/src/Oms.Application/Shared/OmsDocHelper.cs @@ -1,6 +1,8 @@ +using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.SystemAggregate.Consts; @@ -155,8 +157,9 @@ public static class OmsDocHelper /// public static string CanonicalizeSalesProductPriceSnapshotJson(object? snapshot) { - var raw = SerializeSnapshotJson(snapshot) ?? string.Empty; - return SerializeSnapshotJson(DeserializePriceSnapshot(raw)) ?? string.Empty; + var dto = DeserializePriceSnapshot(SerializeSnapshotJson(snapshot)); + StabilizePriceSnapshotDynamicJson(dto); + return SerializeSnapshotJson(dto) ?? string.Empty; } /// @@ -173,17 +176,19 @@ public static class OmsDocHelper /// public static string CanonicalizeChannelPriceSnapshotJson(object? snapshot) { - var raw = SerializeSnapshotJson(snapshot) ?? string.Empty; - return SerializeSnapshotJson(DeserializeChannelPriceSnapshot(raw)) ?? string.Empty; + var dto = DeserializeChannelPriceSnapshot(SerializeSnapshotJson(snapshot)); + StabilizeChannelPriceSnapshotDynamicJson(dto); + return SerializeSnapshotJson(dto) ?? string.Empty; } /// - /// 发布同步态哈希:canonical + 去掉 PackVersion/AreaName 等钉包展示字段。 + /// 发布同步态哈希:canonical + 去掉 PackVersion/AreaName,并稳定动态 JSON 数字形态(133 vs 133.0、0 vs 0.0000)。 /// public static string HashSalesProductPriceSnapshotForSync(object? snapshot) { var dto = DeserializePriceSnapshot(SerializeSnapshotJson(snapshot)); PublishSnapshotNormalizer.StripVolatilePackPinsForSyncCompare(dto); + StabilizePriceSnapshotDynamicJson(dto); return SerializeSnapshotJson(dto) ?? string.Empty; } @@ -198,9 +203,99 @@ public static class OmsDocHelper { var dto = DeserializeChannelPriceSnapshot(SerializeSnapshotJson(snapshot)); PublishSnapshotNormalizer.StripVolatilePackPinsForSyncCompare(dto); + StabilizeChannelPriceSnapshotDynamicJson(dto); return SerializeSnapshotJson(dto) ?? string.Empty; } + /// + /// 把 FactorValuesJson / CustomerGroupValues / CalculatedValue 收到同一数字口径,避免 sync-status 误报待发布。 + /// + private static void StabilizePriceSnapshotDynamicJson(SalesProductPricePublishSnapshotDto? snapshot) + { + if (snapshot?.Prices == null) + { + return; + } + + foreach (var price in snapshot.Prices) + { + StabilizePriceDetails(price.Details); + } + } + + private static void StabilizeChannelPriceSnapshotDynamicJson(ServiceChannelPricePublishSnapshotDto? snapshot) + { + if (snapshot?.Prices == null) + { + return; + } + + foreach (var price in snapshot.Prices) + { + StabilizePriceDetails(price.Details); + } + } + + private static void StabilizePriceDetails(List? details) + { + if (details == null) + { + return; + } + + foreach (var detail in details) + { + detail.FactorValuesJson = StabilizeDynamicJsonValue(detail.FactorValuesJson); + detail.CustomerGroupValues = StabilizeDynamicJsonValue(detail.CustomerGroupValues); + if (detail.CalculatedValue is { } calculated) + { + detail.CalculatedValue = StabilizeDecimal(calculated); + } + } + } + + /// + /// JsonElement / Dictionary 等动态 JSON 统一走 ConvertJsonElement,整数形态的小数收成 long。 + /// + private static object? StabilizeDynamicJsonValue(object? value) + { + if (value is null) + { + return null; + } + + string? json = value switch + { + JsonElement element => element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + ? null + : element.GetRawText(), + JsonNode node => node.ToJsonString(), + string text => string.IsNullOrWhiteSpace(text) ? null : text, + _ => SerializeJson(value) + }; + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + using var document = JsonDocument.Parse(json); + return ConvertJsonElement(document.RootElement); + } + + private static decimal StabilizeDecimal(decimal value) + { + // 去掉尾随 0:0.0000 → 0,133.0 → 133(再由序列化写成整型 JSON) + var normalized = decimal.Parse(value.ToString("G29"), CultureInfo.InvariantCulture); + if (normalized == decimal.Truncate(normalized) + && normalized >= long.MinValue + && normalized <= long.MaxValue) + { + return normalized; + } + + return normalized; + } + /// /// 解析渠道买价发布 Content,供列表/对比 API 使用(兼容旧快照审计字段)。 /// @@ -227,15 +322,34 @@ public static class OmsDocHelper .Select(ConvertJsonElement) .ToList(), JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.TryGetInt64(out var integer) - ? integer - : element.GetDecimal(), + JsonValueKind.Number => ConvertJsonNumber(element), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null or JsonValueKind.Undefined => null, _ => element.GetRawText() }; + /// + /// 数字稳定化:能无损落成整型的一律 long,避免 133 / 133.0 导致发布 Content 与 LIVE 哈希不一致。 + /// + private static object ConvertJsonNumber(JsonElement element) + { + if (element.TryGetInt64(out var integer)) + { + return integer; + } + + var decimalValue = StabilizeDecimal(element.GetDecimal()); + if (decimalValue == decimal.Truncate(decimalValue) + && decimalValue >= long.MinValue + && decimalValue <= long.MaxValue) + { + return (long)decimalValue; + } + + return decimalValue; + } + /// /// 反序列化 JSON 字符串数组。 /// diff --git a/src/Oms.Tests/OmsDocHelperTests.cs b/src/Oms.Tests/OmsDocHelperTests.cs index 7ad1d41..b86fae8 100644 --- a/src/Oms.Tests/OmsDocHelperTests.cs +++ b/src/Oms.Tests/OmsDocHelperTests.cs @@ -107,4 +107,107 @@ public class OmsDocHelperTests typeName.Should().Be("中转仓"); } + + [Fact] + public void HashSalesProductPriceSnapshotForSync_ShouldIgnoreNumberFormatNoise() + { + // LIVE:DeserializeJsonObject 常把价收到 long;历史 Content 里常见 133.0 / 0.0000 + var live = """ + { + "salesProductId": 8, + "priceAreaId": 3, + "priceAreaName": "美国分区", + "priceAreaRefs": [ + { + "areaId": 3, + "areaName": "美国分区", + "packVersion": "20261007164410403", + "contentHash": "e14ccf7436f001743ba9d45e0ec95f72285abd70b161fe91591766b909cefc80" + } + ], + "prices": [ + { + "id": 13, + "serialNumber": 1, + "status": 2, + "priceName": "运费", + "costItem": "freight", + "costUnit": "kg", + "currency": "CNY", + "chargingCondition": "无", + "factors": [], + "details": [ + { + "id": 1511, + "priceId": 13, + "factorValuesJson": { "仓库": "2", "重量": "(0-0.5]" }, + "calculationFormula": "total", + "calculatedValue": 0, + "customerGroupValues": { "全部客户": 133 } + } + ] + } + ] + } + """; + var online = """ + { + "salesProductId": 8, + "priceAreaId": 3, + "priceAreaName": "美国分区", + "priceAreaRefs": [ + { + "areaId": 3, + "areaName": "美国分区", + "packVersion": "20261007164410403", + "contentHash": "e14ccf7436f001743ba9d45e0ec95f72285abd70b161fe91591766b909cefc80" + } + ], + "prices": [ + { + "id": 13, + "serialNumber": 1, + "status": 2, + "priceName": "运费", + "costItem": "freight", + "costUnit": "kg", + "currency": "CNY", + "chargingCondition": "无", + "factors": [], + "details": [ + { + "id": 1511, + "priceId": 13, + "factorValuesJson": { "仓库": "2", "重量": "(0-0.5]" }, + "calculationFormula": "total", + "calculatedValue": 0.0000, + "customerGroupValues": { "全部客户": 133.0 } + } + ] + } + ] + } + """; + + var liveHash = OmsDocHelper.HashSalesProductPriceSnapshotForSync( + OmsDocHelper.DeserializePriceSnapshot(live)); + var onlineHash = OmsDocHelper.HashSalesProductPriceSnapshotForSync( + OmsDocHelper.DeserializePriceSnapshot(online)); + + liveHash.Should().Be(onlineHash); + liveHash.Should().Contain(":133}"); + liveHash.Should().Contain("\"calculatedValue\":0"); + liveHash.Should().NotContain("133.0"); + liveHash.Should().NotContain("0.0000"); + } + + [Fact] + public void DeserializeJsonObject_ShouldCoerceIntegerValuedDecimalsToLong() + { + var result = OmsDocHelper.DeserializeJsonObject("""{"全部客户":133.0,"VIP":12.5}"""); + + var map = result.Should().BeOfType>().Subject; + map["全部客户"].Should().Be(133L); + map["VIP"].Should().Be(12.5m); + } } -- 2.45.2