From 6852135774f4a2e8cc60db43be183adb77910b91 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 9 Oct 2026 14:12:36 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E4=BB=93=E5=BA=93=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E6=8E=92=E5=BA=8F=E5=A4=87=E6=B3=A8=E4=B8=8E=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=B3=A8=E5=86=8C=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仓库地址增加排序/备注字段与迁移;用户创建时对邮箱冲突等给出明确中文提示。 Co-authored-by: Cursor --- db.sql | 2 + sql/admin1008.sql | 41 ++ sql/migrate_warehouse_address_sort_remark.sql | 39 ++ .../Dtos/System/CreateWarehouseInput.cs | 11 + .../Dtos/System/UpdateWarehouseInput.cs | 11 + .../Dtos/System/WarehouseOutput.cs | 10 + .../Dtos/System/WarehousePaginatedOutput.cs | 10 + .../Services/Rbac/OmsUserService.cs | 184 +++++-- .../Services/System/WarehouseService.cs | 21 +- .../Shared/OmsRbacRegistrationGuard.cs | 474 ++++++++++++++++-- .../Entities/WarehouseAddress.cs | 12 + 11 files changed, 737 insertions(+), 78 deletions(-) create mode 100644 sql/migrate_warehouse_address_sort_remark.sql diff --git a/db.sql b/db.sql index b2c744a..f55db57 100644 --- a/db.sql +++ b/db.sql @@ -1329,6 +1329,8 @@ CREATE TABLE `t_warehouse_address` ( `Recipient` VARCHAR(64) NULL COMMENT '收件人', `ContactPhone` VARCHAR(32) NULL COMMENT '联系电话', `ShippingAddress` TEXT COMMENT '收货地址', + `Sort` INT NULL DEFAULT NULL COMMENT '展示排序,越小越靠前', + `Remark` VARCHAR(512) NULL DEFAULT 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 '创建人', diff --git a/sql/admin1008.sql b/sql/admin1008.sql index b516377..9c49440 100644 --- a/sql/admin1008.sql +++ b/sql/admin1008.sql @@ -624,3 +624,44 @@ ALTER TABLE `t_customer_export_qualification` MODIFY COLUMN `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人', MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间'; + +-- ============================================================================= +-- migrate_warehouse_address_sort_remark.sql +-- 仓库地址:展示排序(选填ï¼? 备注(选填ï¼? +-- ============================================================================= +SET @db := DATABASE(); + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_warehouse_address' + AND COLUMN_NAME = 'Sort' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Sort` INT NULL DEFAULT NULL COMMENT ''展示排序,越小越靠前'' AFTER `ShippingAddress`', + 'SELECT ''Sort 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_warehouse_address' + AND COLUMN_NAME = 'Remark' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Remark` VARCHAR(512) NULL DEFAULT NULL COMMENT ''备注'' AFTER `Sort`', + 'SELECT ''Remark already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + diff --git a/sql/migrate_warehouse_address_sort_remark.sql b/sql/migrate_warehouse_address_sort_remark.sql new file mode 100644 index 0000000..e4c047b --- /dev/null +++ b/sql/migrate_warehouse_address_sort_remark.sql @@ -0,0 +1,39 @@ +-- ============================================================================= +-- migrate_warehouse_address_sort_remark.sql +-- 仓库地址:展示排序(选填)+ 备注(选填) +-- ============================================================================= +SET @db := DATABASE(); + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_warehouse_address' + AND COLUMN_NAME = 'Sort' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Sort` INT NULL DEFAULT NULL COMMENT ''展示排序,越小越靠前'' AFTER `ShippingAddress`', + 'SELECT ''Sort 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_warehouse_address' + AND COLUMN_NAME = 'Remark' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Remark` VARCHAR(512) NULL DEFAULT NULL COMMENT ''备注'' AFTER `Sort`', + 'SELECT ''Remark already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs b/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs index 50c4a90..6e2c66b 100644 --- a/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs +++ b/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs @@ -37,4 +37,15 @@ public class CreateWarehouseInput /// 发货地址。 /// public string? ShippingAddress { get; set; } + + /// + /// 展示排序(越小越靠前;可不填)。 + /// + public int? Sort { get; set; } + + /// + /// 备注(可不填)。 + /// + [StringLength(512)] + public string? Remark { get; set; } } diff --git a/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs b/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs index ea8b6af..77b89c5 100644 --- a/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs +++ b/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs @@ -43,4 +43,15 @@ public class UpdateWarehouseInput /// 发货地址。 /// public string? ShippingAddress { get; set; } + + /// + /// 展示排序(越小越靠前;可不填)。 + /// + public int? Sort { get; set; } + + /// + /// 备注(可不填)。 + /// + [StringLength(512)] + public string? Remark { get; set; } } diff --git a/src/Oms.Application/Dtos/System/WarehouseOutput.cs b/src/Oms.Application/Dtos/System/WarehouseOutput.cs index 95bf877..26314b6 100644 --- a/src/Oms.Application/Dtos/System/WarehouseOutput.cs +++ b/src/Oms.Application/Dtos/System/WarehouseOutput.cs @@ -35,6 +35,16 @@ public class WarehouseOutput /// public string? ShippingAddress { get; set; } + /// + /// 展示排序(越小越靠前;为空则排在有排序值的记录之后)。 + /// + public int? Sort { get; set; } + + /// + /// 备注。 + /// + public string? Remark { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs b/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs index a6b792e..1067733 100644 --- a/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs +++ b/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs @@ -40,6 +40,16 @@ public class WarehousePaginatedOutput /// public string? ShippingAddress { get; set; } + /// + /// 展示排序(越小越靠前;为空则排在有排序值的记录之后)。 + /// + public int? Sort { get; set; } + + /// + /// 备注。 + /// + public string? Remark { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs index 8abb5fb..46a3439 100644 --- a/src/Oms.Application/Services/Rbac/OmsUserService.cs +++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs @@ -5,11 +5,13 @@ using Fake.Rbac.Application.Dtos.Role; using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Services; using Fake.Rbac.Domain.Managers; +using Fake.Rbac.Infrastructure; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Fake.ObjectStorage; using Oms.Application.Dtos.Rbac; using Oms.Application.Rbac; +using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Enums; @@ -27,6 +29,7 @@ public class OmsUserService( AccountManager accountManager, UserProfileRepository profileRepository, IEmployeeRepository employeeRepository, + FakeRbacDbContext rbacDbContext, IObjectStorage fileStorage) : ApplicationService { /// @@ -94,34 +97,78 @@ public class OmsUserService( [FromBody] OmsUserCreateInput input, CancellationToken cancellationToken = default) { - var employeeNo = await ResolveEmployeeNoForCreateAsync(input.EmployeeNo, cancellationToken); - var user = await userService.CreateAsync(new UserCreateDto - { - Name = input.Name, - Account = input.Account, - Password = input.Password, - Email = input.Email, - Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), - OrganizationId = input.OrganizationId, - RoleIds = input.RoleIds, - }, cancellationToken); + var account = input.Account?.Trim() ?? string.Empty; + OmsRbacRegistrationGuard.ValidateAdminUserFields( + input.Name, + account, + input.Password, + input.Email, + input.Phone, + input.WechatId, + requirePassword: true); - await profileRepository.SaveAsync( - user.Id, - new UserProfileData - { - Phone = input.Phone, - EmployeeNo = employeeNo, - WechatId = input.WechatId, - WechatQrCode = input.WechatQrCode, - WecomKfUrl = input.WecomKfUrl, - }, + var employeeNo = await ResolveEmployeeNoForCreateAsync(input.EmployeeNo, cancellationToken); + await OmsRbacRegistrationGuard.EnsureLoginIdentityAvailableAsync( + rbacDbContext, + account, + input.Email, + forAdminUserCreate: true, + excludeUserId: null, + cancellationToken); + await OmsRbacRegistrationGuard.EnsurePhoneAvailableAsync( + rbacDbContext, + input.Phone, + excludeUserId: null, + cancellationToken); + await OmsRbacRegistrationGuard.EnsureWechatIdAvailableAsync( + rbacDbContext, + input.WechatId, + excludeUserId: null, + cancellationToken); + await OmsRbacRegistrationGuard.EnsureOrganizationExistsAsync( + rbacDbContext, + input.OrganizationId, + cancellationToken); + await OmsRbacRegistrationGuard.EnsureRolesExistAsync( + rbacDbContext, + input.RoleIds, cancellationToken); - await EnsureEmployeeProfileAsync(user.Id, input, cancellationToken); + try + { + var user = await userService.CreateAsync(new UserCreateDto + { + Name = input.Name!.Trim(), + Account = account, + Password = input.Password, + // 禁止 Email='':uk_user_email 会把空串当成占用 + Email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim(), + Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), + OrganizationId = input.OrganizationId, + RoleIds = input.RoleIds, + }, cancellationToken); - var profile = await profileRepository.GetAsync(user.Id, cancellationToken); - return MapUserDetail(user, profile); + await profileRepository.SaveAsync( + user.Id, + new UserProfileData + { + Phone = input.Phone, + EmployeeNo = employeeNo, + WechatId = input.WechatId, + WechatQrCode = input.WechatQrCode, + WecomKfUrl = input.WecomKfUrl, + }, + cancellationToken); + + await EnsureEmployeeProfileAsync(user.Id, input, cancellationToken); + + var profile = await profileRepository.GetAsync(user.Id, cancellationToken); + return MapUserDetail(user, profile); + } + catch (Exception ex) when (ex is not BusinessException) + { + throw OmsRbacRegistrationGuard.ToSaveBusinessException(ex, forAdminUserCreate: true); + } } /// @@ -133,30 +180,75 @@ public class OmsUserService( [FromBody] OmsUserUpdateInput input, CancellationToken cancellationToken = default) { - var user = await userService.UpdateAsync( - id, - new UserUpdateDto - { - Name = input.Name, - Email = input.Email, - Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), - OrganizationId = input.OrganizationId, - }, + if (id == Guid.Empty) + { + throw new BusinessException("用户 Id 无效"); + } + + // 更新不改登录账号/密码 + OmsRbacRegistrationGuard.ValidateAdminUserFields( + input.Name, + account: "placeholder", + password: null, + input.Email, + input.Phone, + input.WechatId, + requirePassword: false); + + var email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim(); + await OmsRbacRegistrationGuard.EnsureEmailAvailableAsync( + rbacDbContext, + email, + excludeUserId: id, + forAdminUserCreate: true, cancellationToken); - await profileRepository.UpdateContactAsync( - id, - new UserProfileData - { - Phone = input.Phone, - WechatId = input.WechatId, - WechatQrCode = input.WechatQrCode, - WecomKfUrl = input.WecomKfUrl, - }, + await OmsRbacRegistrationGuard.EnsurePhoneAvailableAsync( + rbacDbContext, + input.Phone, + excludeUserId: id, + cancellationToken); + await OmsRbacRegistrationGuard.EnsureWechatIdAvailableAsync( + rbacDbContext, + input.WechatId, + excludeUserId: id, + cancellationToken); + await OmsRbacRegistrationGuard.EnsureOrganizationExistsAsync( + rbacDbContext, + input.OrganizationId, cancellationToken); - var profile = await profileRepository.GetAsync(id, cancellationToken); - return MapUserDetail(user, profile); + try + { + var user = await userService.UpdateAsync( + id, + new UserUpdateDto + { + Name = input.Name!.Trim(), + Email = email, + Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), + OrganizationId = input.OrganizationId, + }, + cancellationToken); + + await profileRepository.UpdateContactAsync( + id, + new UserProfileData + { + Phone = input.Phone, + WechatId = input.WechatId, + WechatQrCode = input.WechatQrCode, + WecomKfUrl = input.WecomKfUrl, + }, + cancellationToken); + + var profile = await profileRepository.GetAsync(id, cancellationToken); + return MapUserDetail(user, profile); + } + catch (Exception ex) when (ex is not BusinessException) + { + throw OmsRbacRegistrationGuard.ToSaveBusinessException(ex, forAdminUserCreate: true); + } } /// @@ -259,12 +351,12 @@ public class OmsUserService( if (!UserProfileRepository.IsValidEmployeeNo(employeeNo)) { - throw new BusinessException("工号格式无效,应为 IDE + 至少 3 位数字,如 IDE001"); + throw new BusinessException($"工号「{employeeNo}」格式无效,应为 IDE + 至少 3 位数字,如 IDE001"); } if (await profileRepository.IsEmployeeNoTakenAsync(employeeNo, null, cancellationToken)) { - throw new BusinessException($"工号已存在:{employeeNo}"); + throw new BusinessException($"工号「{employeeNo}」已存在,请更换工号后再创建"); } return employeeNo; diff --git a/src/Oms.Application/Services/System/WarehouseService.cs b/src/Oms.Application/Services/System/WarehouseService.cs index 8532769..64dc16f 100644 --- a/src/Oms.Application/Services/System/WarehouseService.cs +++ b/src/Oms.Application/Services/System/WarehouseService.cs @@ -33,13 +33,16 @@ public class WarehouseService : ApplicationService var pageIndex = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); - var entities = await WarehouseAddressRepository.GetPagedListAsync( + // 有 Sort 的按升序靠前;无 Sort 的靠后;同序再按 Id + var (entities, totalCount) = await RepositoryPageHelper.GetPageAsync( + WarehouseAddressRepository, predicate, pageIndex, pageSize, - new Dictionary { [nameof(WarehouseAddress.UpdateTime)] = false, [nameof(WarehouseAddress.Id)] = false }); + q => q.OrderBy(x => x.Sort == null) + .ThenBy(x => x.Sort) + .ThenBy(x => x.Id)); - var totalCount = await WarehouseAddressRepository.CountAsync(predicate); var items = entities.Select(entity => { var output = ObjectMapper.Map(entity); @@ -97,6 +100,8 @@ public class WarehouseService : ApplicationService deleted.Recipient = input.Recipient; deleted.ContactPhone = input.ContactPhone; deleted.ShippingAddress = input.ShippingAddress; + deleted.Sort = input.Sort; + deleted.Remark = NormalizeRemark(input.Remark); await WarehouseAddressRepository.UpdateAsync(deleted); return; } @@ -108,6 +113,8 @@ public class WarehouseService : ApplicationService Recipient = input.Recipient, ContactPhone = input.ContactPhone, ShippingAddress = input.ShippingAddress, + Sort = input.Sort, + Remark = NormalizeRemark(input.Remark), }; await WarehouseAddressRepository.InsertAsync(entity); @@ -141,6 +148,8 @@ public class WarehouseService : ApplicationService entity.Recipient = input.Recipient; entity.ContactPhone = input.ContactPhone; entity.ShippingAddress = input.ShippingAddress; + entity.Sort = input.Sort; + entity.Remark = NormalizeRemark(input.Remark); await WarehouseAddressRepository.UpdateAsync(entity); } @@ -180,4 +189,10 @@ public class WarehouseService : ApplicationService return entity; } + + private static string? NormalizeRemark(string? remark) + { + var value = remark?.Trim(); + return string.IsNullOrEmpty(value) ? null : value; + } } diff --git a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs index e726af9..6241a72 100644 --- a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs +++ b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs @@ -1,3 +1,5 @@ +using System.Net.Mail; +using System.Text.RegularExpressions; using Fake.Application; using Fake.Rbac.Infrastructure; using Microsoft.EntityFrameworkCore; @@ -5,32 +7,276 @@ using Microsoft.EntityFrameworkCore; namespace Oms.Application.Shared; /// -/// 微信/客户注册前校验 RBAC 登录账号,避免 user 表已存在但 t_customer 未创建时 EF 抛出英文 DbUpdateException。 +/// 微信/客户注册、后台建用户前校验 RBAC 登录身份,避免 EF 抛出英文 DbUpdateException。 /// public static class OmsRbacRegistrationGuard { + private static readonly Regex PhonePattern = new(@"^1\d{10}$", RegexOptions.Compiled); + public static async Task EnsureLoginIdentityAvailableAsync( FakeRbacDbContext rbacDbContext, string account, string? email, CancellationToken cancellationToken = default) + { + await EnsureLoginIdentityAvailableAsync( + rbacDbContext, + account, + email, + forAdminUserCreate: false, + excludeUserId: null, + cancellationToken); + } + + /// + /// 校验登录账号/邮箱是否可用。 + /// 为 true 时提示面向后台建员工用户(会说明可能已被客户账号占用)。 + /// + public static async Task EnsureLoginIdentityAvailableAsync( + FakeRbacDbContext rbacDbContext, + string account, + string? email, + bool forAdminUserCreate, + CancellationToken cancellationToken = default) + { + await EnsureLoginIdentityAvailableAsync( + rbacDbContext, + account, + email, + forAdminUserCreate, + excludeUserId: null, + cancellationToken); + } + + public static async Task EnsureLoginIdentityAvailableAsync( + FakeRbacDbContext rbacDbContext, + string account, + string? email, + bool forAdminUserCreate, + Guid? excludeUserId, + CancellationToken cancellationToken = default) { var normalizedAccount = account.Trim(); var normalizedEmail = string.IsNullOrWhiteSpace(email) ? null : email.Trim(); - if (await ExistsActiveUserByAccountAsync(rbacDbContext, normalizedAccount, cancellationToken)) + if (await ExistsUserByAccountAsync(rbacDbContext, normalizedAccount, excludeUserId, cancellationToken)) { - throw new BusinessException("登录账号已存在,请直接绑定或使用其他账号"); + throw new BusinessException( + forAdminUserCreate + ? $"登录账号「{normalizedAccount}」已存在。客户门户注册也会占用同一登录账号,请更换账号后再创建员工用户" + : "登录账号已存在,请直接绑定或使用其他账号"); } if (normalizedEmail != null - && await ExistsActiveUserByEmailAsync(rbacDbContext, normalizedEmail, cancellationToken)) + && await ExistsUserByEmailAsync(rbacDbContext, normalizedEmail, excludeUserId, cancellationToken)) { - throw new BusinessException("邮箱已被占用,请更换邮箱或直接绑定已有账号"); + throw new BusinessException( + forAdminUserCreate + ? $"邮箱「{normalizedEmail}」已被占用。客户门户注册也会占用同一邮箱,请更换邮箱后再创建员工用户" + : "邮箱已被占用,请更换邮箱或直接绑定已有账号"); } } - public static BusinessException ToSaveBusinessException(Exception ex) + /// + /// 后台创建/更新员工用户时的字段格式与必填校验(不含唯一性)。 + /// + public static void ValidateAdminUserFields( + string? name, + string? account, + string? password, + string? email, + string? phone, + string? wechatId, + bool requirePassword) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new BusinessException("姓名不能为空"); + } + + if (name.Trim().Length > 64) + { + throw new BusinessException($"姓名过长(当前 {name.Trim().Length} 字,最多 64 字)"); + } + + if (string.IsNullOrWhiteSpace(account)) + { + throw new BusinessException("登录账号不能为空"); + } + + if (account.Trim().Length > 64) + { + throw new BusinessException($"登录账号过长(当前 {account.Trim().Length} 字,最多 64 字)"); + } + + if (requirePassword) + { + if (string.IsNullOrWhiteSpace(password)) + { + throw new BusinessException("密码不能为空"); + } + + if (password.Length < 6) + { + throw new BusinessException("密码长度不能少于6位"); + } + + if (password.Length > 128) + { + throw new BusinessException("密码过长,请控制在 128 位以内"); + } + } + + if (!string.IsNullOrWhiteSpace(email)) + { + var trimmedEmail = email.Trim(); + if (trimmedEmail.Length > 128) + { + throw new BusinessException($"邮箱过长(当前 {trimmedEmail.Length} 字,最多 128 字)"); + } + + if (!IsValidEmail(trimmedEmail)) + { + throw new BusinessException($"邮箱格式不正确:{trimmedEmail}"); + } + } + + if (!string.IsNullOrWhiteSpace(phone)) + { + var trimmedPhone = phone.Trim(); + if (!PhonePattern.IsMatch(trimmedPhone)) + { + throw new BusinessException($"手机号格式不正确:{trimmedPhone}(应为 11 位中国大陆手机号)"); + } + } + + if (!string.IsNullOrWhiteSpace(wechatId) && wechatId.Trim().Length > 64) + { + throw new BusinessException($"微信号过长(当前 {wechatId.Trim().Length} 字,最多 64 字)"); + } + } + + public static async Task EnsureOrganizationExistsAsync( + FakeRbacDbContext rbacDbContext, + Guid? organizationId, + CancellationToken cancellationToken = default) + { + if (organizationId is null || organizationId == Guid.Empty) + { + return; + } + + var hit = await rbacDbContext.Database + .SqlQuery( + $""" + SELECT 1 AS Value + FROM `organization` + WHERE `Id` = {organizationId.Value.ToString()} AND `IsDeleted` = 0 + LIMIT 1 + """) + .FirstOrDefaultAsync(cancellationToken); + + if (!hit.HasValue) + { + throw new BusinessException($"所属组织不存在或已删除({organizationId}),请重新选择组织"); + } + } + + public static async Task EnsureRolesExistAsync( + FakeRbacDbContext rbacDbContext, + IEnumerable? roleIds, + CancellationToken cancellationToken = default) + { + if (roleIds == null) + { + return; + } + + var ids = roleIds.Where(x => x != Guid.Empty).Distinct().ToList(); + if (ids.Count == 0) + { + return; + } + + foreach (var roleId in ids) + { + var hit = await rbacDbContext.Database + .SqlQuery( + $""" + SELECT 1 AS Value + FROM `role` + WHERE `Id` = {roleId.ToString()} AND `IsDeleted` = 0 + LIMIT 1 + """) + .FirstOrDefaultAsync(cancellationToken); + + if (!hit.HasValue) + { + throw new BusinessException($"角色不存在或已删除({roleId}),请重新选择角色"); + } + } + } + + public static async Task EnsureEmailAvailableAsync( + FakeRbacDbContext rbacDbContext, + string? email, + Guid? excludeUserId, + bool forAdminUserCreate, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(email)) + { + return; + } + + var normalizedEmail = email.Trim(); + if (await ExistsUserByEmailAsync(rbacDbContext, normalizedEmail, excludeUserId, cancellationToken)) + { + throw new BusinessException( + forAdminUserCreate + ? $"邮箱「{normalizedEmail}」已被占用。客户门户注册也会占用同一邮箱,请更换邮箱后再保存" + : "邮箱已被占用,请更换邮箱或直接绑定已有账号"); + } + } + + public static async Task EnsurePhoneAvailableAsync( + FakeRbacDbContext rbacDbContext, + string? phone, + Guid? excludeUserId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(phone)) + { + return; + } + + var normalized = phone.Trim(); + if (await ExistsUserByPhoneAsync(rbacDbContext, normalized, excludeUserId, cancellationToken)) + { + throw new BusinessException( + $"手机号「{normalized}」已被占用。客户门户注册也会占用同一手机号,请更换手机号后再保存"); + } + } + + public static async Task EnsureWechatIdAvailableAsync( + FakeRbacDbContext rbacDbContext, + string? wechatId, + Guid? excludeUserId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(wechatId)) + { + return; + } + + var normalized = wechatId.Trim(); + if (await ExistsUserByWechatIdAsync(rbacDbContext, normalized, excludeUserId, cancellationToken)) + { + throw new BusinessException($"微信号「{normalized}」已被占用,请更换后再保存"); + } + } + + public static BusinessException ToSaveBusinessException(Exception ex, bool forAdminUserCreate = false) { var message = ExtractDeepMessage(ex); if (ContainsDuplicateKey(message)) @@ -39,19 +285,41 @@ public static class OmsRbacRegistrationGuard || message.Contains("uk_user_account", StringComparison.OrdinalIgnoreCase) || message.Contains("IX_user_Account", StringComparison.OrdinalIgnoreCase)) { - return new BusinessException("登录账号已存在,请直接绑定或使用其他账号"); + return new BusinessException( + forAdminUserCreate + ? "登录账号已存在。客户门户注册也会占用同一登录账号,请更换账号后再创建员工用户" + : "登录账号已存在,请直接绑定或使用其他账号"); } if (message.Contains("Email", StringComparison.OrdinalIgnoreCase) || message.Contains("uk_user_email", StringComparison.OrdinalIgnoreCase)) { - return new BusinessException("邮箱已被占用,请更换邮箱或直接绑定已有账号"); + return new BusinessException( + forAdminUserCreate + ? "邮箱已被占用。客户门户注册也会占用同一邮箱,请更换邮箱后再创建员工用户" + : "邮箱已被占用,请更换邮箱或直接绑定已有账号"); } if (message.Contains("Phone", StringComparison.OrdinalIgnoreCase) + || message.Contains("uk_user_phone", StringComparison.OrdinalIgnoreCase) || message.Contains("uk_cus_phone", StringComparison.OrdinalIgnoreCase)) { - return new BusinessException("手机号已存在"); + return new BusinessException( + forAdminUserCreate + ? "手机号已被占用。客户门户注册也会占用同一手机号,请更换手机号后再保存" + : "手机号已存在"); + } + + if (message.Contains("EmployeeNo", StringComparison.OrdinalIgnoreCase) + || message.Contains("uk_user_employee", StringComparison.OrdinalIgnoreCase)) + { + return new BusinessException("工号已被占用,请更换工号后再保存"); + } + + if (message.Contains("WechatId", StringComparison.OrdinalIgnoreCase) + || message.Contains("uk_user_wechat", StringComparison.OrdinalIgnoreCase)) + { + return new BusinessException("微信号已被占用,请更换后再保存"); } if (message.Contains("uk_cus_email", StringComparison.OrdinalIgnoreCase)) @@ -66,41 +334,125 @@ public static class OmsRbacRegistrationGuard return new BusinessException("当前微信已绑定账号"); } - return new BusinessException("账号信息已存在,请直接绑定或更换手机号/邮箱"); + return new BusinessException( + forAdminUserCreate + ? "账号信息与已有用户冲突(登录账号/邮箱/手机号等唯一字段),请检查后更换" + : "账号信息已存在,请直接绑定或更换手机号/邮箱"); } if (message.Contains("Data too long", StringComparison.OrdinalIgnoreCase) || message.Contains("1406", StringComparison.OrdinalIgnoreCase)) { - if (message.Contains("Avatar", StringComparison.OrdinalIgnoreCase)) + var field = ResolveTooLongField(message, forAdminUserCreate); + if (field != null) { - return new BusinessException("微信头像地址过长,请稍后重试或联系客服"); + return new BusinessException(field); } - if (message.Contains("Nickname", StringComparison.OrdinalIgnoreCase) - || message.Contains("CustomerName", StringComparison.OrdinalIgnoreCase) - || message.Contains("Name", StringComparison.OrdinalIgnoreCase)) - { - return new BusinessException("客户名称或微信昵称过长,请修改后重试"); - } - - return new BusinessException("注册信息字段过长,请缩短客户名称后重试"); + return new BusinessException( + forAdminUserCreate + ? "保存失败:某个字段内容过长,请缩短后重试" + : "注册信息字段过长,请缩短客户名称后重试"); } if (message.Contains("foreign key constraint fails", StringComparison.OrdinalIgnoreCase) || message.Contains("1452", StringComparison.OrdinalIgnoreCase)) { - return new BusinessException("客户角色未正确初始化,请联系管理员"); + if (message.Contains("organization", StringComparison.OrdinalIgnoreCase) + || message.Contains("Organization", StringComparison.OrdinalIgnoreCase)) + { + return new BusinessException("所属组织不存在或已删除,请重新选择组织"); + } + + if (message.Contains("role", StringComparison.OrdinalIgnoreCase) + || message.Contains("Role", StringComparison.OrdinalIgnoreCase)) + { + return new BusinessException("角色不存在或已删除,请重新选择角色"); + } + + return new BusinessException("关联数据无效(组织/角色),请检查后重试"); } if (message.Contains("Cannot insert duplicate key", StringComparison.OrdinalIgnoreCase) || message.Contains("2627", StringComparison.OrdinalIgnoreCase)) { - return new BusinessException("账号信息已存在,请直接绑定或更换手机号/邮箱"); + return new BusinessException( + forAdminUserCreate + ? "账号信息与已有用户冲突(登录账号/邮箱/手机号等唯一字段),请检查后更换" + : "账号信息已存在,请直接绑定或更换手机号/邮箱"); } var reason = ExtractActionableReason(ex); - return new BusinessException($"注册失败:{reason}"); + return new BusinessException( + forAdminUserCreate ? $"保存用户失败:{reason}" : $"注册失败:{reason}"); + } + + private static string? ResolveTooLongField(string message, bool forAdminUserCreate) + { + if (message.Contains("Avatar", StringComparison.OrdinalIgnoreCase)) + { + return forAdminUserCreate ? "头像地址过长,请更换后重试" : "微信头像地址过长,请稍后重试或联系客服"; + } + + if (message.Contains("Account", StringComparison.OrdinalIgnoreCase)) + { + return "登录账号过长,请缩短后重试"; + } + + if (message.Contains("Email", StringComparison.OrdinalIgnoreCase)) + { + return "邮箱过长,请缩短后重试"; + } + + if (message.Contains("Phone", StringComparison.OrdinalIgnoreCase)) + { + return "手机号过长,请检查后重试"; + } + + if (message.Contains("EmployeeNo", StringComparison.OrdinalIgnoreCase)) + { + return "工号过长,请检查后重试"; + } + + if (message.Contains("WechatId", StringComparison.OrdinalIgnoreCase)) + { + return "微信号过长,请缩短后重试"; + } + + if (message.Contains("WechatQrCode", StringComparison.OrdinalIgnoreCase) + || message.Contains("WecomKfUrl", StringComparison.OrdinalIgnoreCase)) + { + return "微信二维码或客服链接过长,请缩短后重试"; + } + + if (message.Contains("Password", StringComparison.OrdinalIgnoreCase) + || message.Contains("PasswordHash", StringComparison.OrdinalIgnoreCase)) + { + return "密码相关字段过长,请缩短密码后重试"; + } + + if (message.Contains("Nickname", StringComparison.OrdinalIgnoreCase) + || message.Contains("CustomerName", StringComparison.OrdinalIgnoreCase) + || message.Contains("`Name`", StringComparison.OrdinalIgnoreCase) + || message.Contains(" column 'Name'", StringComparison.OrdinalIgnoreCase)) + { + return forAdminUserCreate ? "姓名过长,请缩短后重试" : "客户名称或微信昵称过长,请修改后重试"; + } + + return null; + } + + private static bool IsValidEmail(string email) + { + try + { + var addr = new MailAddress(email); + return string.Equals(addr.Address, email, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } } private static string ExtractActionableReason(Exception ex) @@ -130,29 +482,93 @@ public static class OmsRbacRegistrationGuard return reason.Length <= 160 ? reason : reason[..160]; } - private static async Task ExistsActiveUserByAccountAsync( + private static async Task ExistsUserByAccountAsync( FakeRbacDbContext rbacDbContext, string account, + Guid? excludeUserId, CancellationToken cancellationToken) { // 唯一索引不按 IsDeleted 区分,软删除账号仍会触发 Duplicate entry。 - var hit = await rbacDbContext.Database + if (excludeUserId is Guid id && id != Guid.Empty) + { + var hit = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} AND `Id` <> {id.ToString()} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return hit.HasValue; + } + + var any = await rbacDbContext.Database .SqlQuery( $"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} LIMIT 1") .FirstOrDefaultAsync(cancellationToken); - return hit.HasValue; + return any.HasValue; } - private static async Task ExistsActiveUserByEmailAsync( + private static async Task ExistsUserByEmailAsync( FakeRbacDbContext rbacDbContext, string email, + Guid? excludeUserId, CancellationToken cancellationToken) { - var hit = await rbacDbContext.Database + if (excludeUserId is Guid id && id != Guid.Empty) + { + var hit = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} AND `Id` <> {id.ToString()} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return hit.HasValue; + } + + var any = await rbacDbContext.Database .SqlQuery( $"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} LIMIT 1") .FirstOrDefaultAsync(cancellationToken); - return hit.HasValue; + return any.HasValue; + } + + private static async Task ExistsUserByPhoneAsync( + FakeRbacDbContext rbacDbContext, + string phone, + Guid? excludeUserId, + CancellationToken cancellationToken) + { + if (excludeUserId is Guid id && id != Guid.Empty) + { + var hit = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} AND `Id` <> {id.ToString()} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return hit.HasValue; + } + + var any = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; + } + + private static async Task ExistsUserByWechatIdAsync( + FakeRbacDbContext rbacDbContext, + string wechatId, + Guid? excludeUserId, + CancellationToken cancellationToken) + { + if (excludeUserId is Guid id && id != Guid.Empty) + { + var hit = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} AND `Id` <> {id.ToString()} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return hit.HasValue; + } + + var any = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; } private static string ExtractDeepMessage(Exception ex) diff --git a/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs b/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs index c9d981e..91d436d 100644 --- a/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs +++ b/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs @@ -15,4 +15,16 @@ public class WarehouseAddress : OmsAuditedAggregateRoot public string? ContactPhone { get; set; } public string? ShippingAddress { get; set; } + + /// + /// 展示排序(越小越靠前;为空则排在有排序值的记录之后)。 + /// + [SugarColumn(IsNullable = true)] + public int? Sort { get; set; } + + /// + /// 备注(选填,展示用)。 + /// + [SugarColumn(Length = 512, IsNullable = true)] + public string? Remark { get; set; } } -- 2.45.2 From 358738c33372bb1af084d7d80980b2d0489270d1 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 9 Oct 2026 16:12:12 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E6=8F=BD=E6=94=B6=E5=8D=95?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=94=B9=E4=BF=A1=E6=81=AF/=E4=BB=B6?= =?UTF-8?q?=E9=87=8D=E5=B0=BA/=E9=87=8D=E7=AE=97=E8=B4=B9=E7=94=A8?= =?UTF-8?q?=EF=BC=8C=E5=88=9B=E5=BB=BA=E5=90=8E=E8=87=AA=E5=8A=A8=E9=A2=84?= =?UTF-8?q?=E6=8A=A5=E4=B8=8B=E6=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 创建成功后自动下游预报回写承运单号并升为已预约;详情可改揽收信息与件重尺并按卖买价重算。 Co-authored-by: Cursor --- .../Dtos/Orders/PickupOrderDtos.cs | 38 +++ .../Orders/PickupOrderFeePushService.cs | 115 +++++++-- .../Services/Orders/PickupOrderService.cs | 239 ++++++++++++++++++ 3 files changed, 375 insertions(+), 17 deletions(-) diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs index d9fd2fd..3633ccd 100644 --- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs +++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs @@ -176,6 +176,44 @@ public class PickupOrderRescheduleInput public DateTime ExpectPickupTime { get; set; } } +/// +/// 修改揽收信息(未支付且 draft/scheduled)。 +/// +public class PickupOrderUpdateInfoInput +{ + [Required] + public int Id { get; set; } + + public int? WarehouseId { get; set; } + + public int? SenderAddressId { get; set; } + + public DateTime? ExpectPickupTime { get; set; } + + public string? CarrierService { get; set; } + + public string? CarrierWaybillNo { get; set; } + + [StringLength(512)] + public string? Remark { get; set; } +} + +/// +/// 修改件重尺(未支付;不改费用,需另行重算)。 +/// +public class PickupOrderUpdateMeasureInput +{ + [Required] + public int Id { get; set; } + + [Required] + public decimal TotalActualWeight { get; set; } + + [Required] + [MinLength(1)] + public List PackageSizes { get; set; } = []; +} + public class PickupOrderRejectItem { public int OrderId { get; set; } diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs index 19eef43..fa7489d 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs @@ -74,15 +74,66 @@ public class PickupOrderFeePushService : IScopedDependency var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo) ?? throw new BusinessException($"揽收单不存在:承运运单号 {carrierWaybillNo}"); - if (pickup.PayStatus == PickupPayStatusCodes.Paid) + EnsureCanReceiveMeasure(pickup); + + return await ApplyMeasureAndBillAsync( + pickup, + input.TotalActualWeight, + sizes, + input.CarrierFreightWeight, + input.CarrierQuotedFreightAmount, + input.CarrierQuotedReceivableAmount, + input.ValueInfoList); + } + + /// + /// 按已保存(或入参)件重尺,用当前承运服务卖价/买价重算出账。 + /// + public async Task RecalcByPickupAsync( + PickupOrder pickup, + decimal? totalActualWeightOverride = null, + IReadOnlyList? sizesOverride = null) + { + EnsureCanReceiveMeasure(pickup); + + var weight = totalActualWeightOverride ?? pickup.TotalActualWeight ?? 0; + if (weight <= 0) { - throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用"); + throw new BusinessException("请先填写实重后再重算费用"); } - if (PickupOrderStatusCodes.IsTerminal(pickup.Status) - && pickup.Status != PickupOrderStatusCodes.Inbound) + var sizes = sizesOverride != null && sizesOverride.Count > 0 + ? NormalizeSizes(sizesOverride.ToList()) + : NormalizeSizes( + OmsDocHelper.DeserializeJson>(pickup.PackageSizesJson)); + if (sizes.Count == 0) { - throw new BusinessException("当前揽收单状态不可接收件重尺"); + throw new BusinessException("请先填写件重尺尺寸后再重算费用"); + } + + return await ApplyMeasureAndBillAsync(pickup, weight, sizes); + } + + public async Task ApplyMeasureAndBillAsync( + PickupOrder pickup, + decimal totalActualWeight, + IReadOnlyList sizes, + decimal? carrierFreightWeight = null, + decimal? carrierQuotedFreightAmount = null, + decimal? carrierQuotedReceivableAmount = null, + List? valueInfoList = null) + { + if (totalActualWeight <= 0) + { + throw new BusinessException("totalActualWeight 必须大于 0"); + } + + var normalizedSizes = sizes is List list + ? NormalizeSizes(list) + : NormalizeSizes(sizes.ToList()); + if (normalizedSizes.Count == 0) + { + throw new BusinessException("packageSizes 不能为空"); } if (string.IsNullOrWhiteSpace(pickup.CarrierService)) @@ -93,14 +144,14 @@ public class PickupOrderFeePushService : IScopedDependency 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 totalVolume = normalizedSizes.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.TotalActualWeight, + ActualWeight = totalActualWeight, Cubic = ParcelBillingCalculator.CalculateCubic(totalVolume, 1, 1) }; @@ -187,17 +238,33 @@ public class PickupOrderFeePushService : IScopedDependency var freight = receivableFees.FirstOrDefault()?.Amount ?? receivableTotal; var other = Math.Max(0, receivableTotal - freight); - pickup.TotalActualWeight = input.TotalActualWeight; + pickup.TotalActualWeight = totalActualWeight; pickup.VolumetricWeight = volumetricWeight; pickup.ChargeableWeight = chargeableWeight; - pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes); - pickup.CarrierFreightWeight = input.CarrierFreightWeight; - pickup.CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount; - pickup.CarrierQuotedReceivableAmount = input.CarrierQuotedReceivableAmount; - pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput + pickup.PackageSizesJson = OmsDocHelper.SerializeJson(normalizedSizes); + if (carrierFreightWeight.HasValue) { - ValueInfoList = input.ValueInfoList ?? [] - }); + pickup.CarrierFreightWeight = carrierFreightWeight; + } + + if (carrierQuotedFreightAmount.HasValue) + { + pickup.CarrierQuotedFreightAmount = carrierQuotedFreightAmount; + } + + if (carrierQuotedReceivableAmount.HasValue) + { + pickup.CarrierQuotedReceivableAmount = carrierQuotedReceivableAmount; + } + + if (valueInfoList != null) + { + pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput + { + ValueInfoList = valueInfoList + }); + } + pickup.ReceivableFeeJson = OmsDocHelper.SerializeJson(receivableFees); pickup.PayableFeeJson = OmsDocHelper.SerializeJson(payableFees); pickup.FreightAmount = freight; @@ -211,15 +278,29 @@ public class PickupOrderFeePushService : IScopedDependency return new PickupOpenApiPushFeeResult { PickupNo = pickup.PickupNo, - TotalActualWeight = input.TotalActualWeight, + TotalActualWeight = totalActualWeight, ChargeableWeight = chargeableWeight, VolumetricWeight = volumetricWeight, ReceivableAmount = receivableTotal, CarrierPayableAmount = payableTotal, - CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount + CarrierQuotedFreightAmount = pickup.CarrierQuotedFreightAmount }; } + private static void EnsureCanReceiveMeasure(PickupOrder pickup) + { + if (pickup.PayStatus == PickupPayStatusCodes.Paid) + { + throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用"); + } + + if (PickupOrderStatusCodes.IsTerminal(pickup.Status) + && pickup.Status != PickupOrderStatusCodes.Inbound) + { + throw new BusinessException("当前揽收单状态不可接收件重尺"); + } + } + private async Task<(decimal VolumetricWeight, decimal ChargeableWeight)> CalculateChargeableAsync( SalesProduct product, OrderWeight weight) diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs index 4ee5162..b6e5d8a 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs @@ -69,6 +69,9 @@ public class PickupOrderService : ApplicationService private IPickupBigBagTrackSyncer PickupBigBagTrackSyncer => LazyServiceProvider.GetRequiredService(); + private PickupOrderFeePushService PickupOrderFeePushService => + LazyServiceProvider.GetRequiredService(); + /// /// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。 /// @@ -274,6 +277,19 @@ public class PickupOrderService : ApplicationService await OrderRepository.UpdateAsync(order); } + // 已选承运服务时:创建后自动预报下游拿承运运单号(失败不影响建单) + if (!string.IsNullOrWhiteSpace(entity.CarrierService)) + { + try + { + await DownstreamForwardCoreAsync(entity); + } + catch + { + // 建单已成功,预报失败由列表「预报下游」重试 + } + } + return await MapOutputAsync(entity, includeOrders: true); } @@ -551,6 +567,213 @@ public class PickupOrderService : ApplicationService return await MapOutputAsync(pickup, includeOrders: true); } + /// + /// 修改揽收信息(未支付且 draft/scheduled)。 + /// + [HttpPost("/order/pickup-order/update-info")] + public async Task UpdateInfoAsync([FromBody] PickupOrderUpdateInfoInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + EnsureUnpaidEditable(pickup); + if (!PickupOrderStatusCodes.IsMutableBinding(pickup.Status)) + { + throw new BusinessException("当前状态不可修改揽收信息"); + } + + var warehouseChanged = false; + var expectTimeChanged = false; + var senderChanged = false; + + if (input.WarehouseId is > 0 && input.WarehouseId.Value != pickup.WarehouseId) + { + var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId.Value); + if (warehouse == null) + { + throw new BusinessException("仓库不存在"); + } + + pickup.WarehouseId = input.WarehouseId.Value; + warehouseChanged = true; + } + + if (input.SenderAddressId is > 0 && input.SenderAddressId.Value != pickup.SenderAddressId) + { + var senderAddress = await CustomerSenderAddressRepository.GetOwnedAsync( + input.SenderAddressId.Value, + pickup.CustomerId); + if (senderAddress == null) + { + throw new BusinessException("揽收地址不存在"); + } + + var addressType = string.IsNullOrWhiteSpace(senderAddress.AddressType) + ? CustomerAddressTypeCodes.Sender + : senderAddress.AddressType; + if (!CustomerAddressTypeCodes.IsPickupEligible(addressType)) + { + throw new BusinessException("揽收单请选择「国内揽收」类型地址"); + } + + string? senderCountryCode = null; + if (senderAddress.Country.HasValue) + { + var country = await CountryRepository.FirstOrDefaultAsync(x => x.Id == senderAddress.Country.Value); + senderCountryCode = country?.CountryCode; + } + + pickup.SenderAddressId = senderAddress.Id; + pickup.SenderName = senderAddress.SenderName ?? string.Empty; + pickup.SenderCompany = senderAddress.Company; + pickup.SenderCountry = senderAddress.Country; + pickup.SenderCountryCode = senderCountryCode; + pickup.SenderProvince = senderAddress.Province; + pickup.SenderCity = senderAddress.City; + pickup.SenderZipCode = senderAddress.ZipCode; + pickup.SenderStreetAddress1 = senderAddress.StreetAddress1 ?? string.Empty; + pickup.SenderStreetAddress2 = senderAddress.StreetAddress2; + pickup.SenderStreetAddress3 = senderAddress.StreetAddress3; + pickup.SenderHouseNumber = senderAddress.HouseNumber; + pickup.SenderPhone = senderAddress.Phone; + pickup.SenderTelePhone = senderAddress.TelePhone; + pickup.SenderEmail = senderAddress.Email; + senderChanged = true; + } + + if (input.ExpectPickupTime.HasValue && input.ExpectPickupTime.Value != default) + { + pickup.ExpectPickupTime = input.ExpectPickupTime.Value; + expectTimeChanged = true; + } + + if (input.CarrierService != null) + { + var carrierService = NormalizeOptional(input.CarrierService); + if (carrierService == null) + { + throw new BusinessException("承运服务不能为空"); + } + + pickup.CarrierService = carrierService; + } + + if (input.CarrierWaybillNo != null) + { + pickup.CarrierWaybillNo = NormalizeOptional(input.CarrierWaybillNo); + } + + if (input.Remark != null) + { + pickup.Remark = NormalizeOptional(input.Remark); + } + + // 写入承运运单号后 draft → scheduled(已预约) + if (!string.IsNullOrWhiteSpace(pickup.CarrierWaybillNo) + && pickup.Status == PickupOrderStatusCodes.Draft) + { + pickup.Status = PickupOrderStatusCodes.Scheduled; + } + + await PickupOrderRepository.UpdateAsync(pickup); + + if (warehouseChanged || expectTimeChanged || senderChanged) + { + var lines = await PickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id); + foreach (var line in lines) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == line.OrderId); + if (order == null) + { + continue; + } + + if (warehouseChanged) + { + order.WarehouseId = pickup.WarehouseId; + } + + if (expectTimeChanged) + { + order.PickupTime = pickup.ExpectPickupTime; + } + + if (senderChanged) + { + order.CustomerSenderAddressId = pickup.SenderAddressId; + } + + await OrderRepository.UpdateAsync(order); + } + } + + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 修改件重尺(未支付;仅落库,不改费用)。 + /// + [HttpPost("/order/pickup-order/update-measure")] + public async Task UpdateMeasureAsync([FromBody] PickupOrderUpdateMeasureInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + EnsureUnpaidEditable(pickup); + if (PickupOrderStatusCodes.IsTerminal(pickup.Status) + && pickup.Status != PickupOrderStatusCodes.Inbound) + { + throw new BusinessException("当前状态不可修改件重尺"); + } + + if (input.TotalActualWeight <= 0) + { + throw new BusinessException("实重必须大于 0"); + } + + var sizes = (input.PackageSizes ?? []) + .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(); + if (sizes.Count == 0) + { + throw new BusinessException("请至少填写一条有效尺寸(长宽高)"); + } + + pickup.TotalActualWeight = input.TotalActualWeight; + pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes); + pickup.UpdateTime = DateTime.Now; + await PickupOrderRepository.UpdateAsync(pickup); + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 按当前件重尺与承运服务重算费用。 + /// + [HttpPost("/order/pickup-order/recalc-fee")] + public async Task RecalcFeeAsync([FromBody] PickupOrderIdInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + EnsureUnpaidEditable(pickup); + await PickupOrderFeePushService.RecalcByPickupAsync(pickup); + return await MapOutputAsync(pickup, includeOrders: true); + } + + private static void EnsureUnpaidEditable(PickupOrder pickup) + { + if (pickup.PayStatus == PickupPayStatusCodes.Paid) + { + throw new BusinessException("已支付不可修改"); + } + + if (pickup.Status is PickupOrderStatusCodes.Cancelled or PickupOrderStatusCodes.Failed) + { + throw new BusinessException("当前状态不可修改"); + } + } + /// /// 客户支付揽收运费(余额)。 /// @@ -930,6 +1153,14 @@ public class PickupOrderService : ApplicationService public async Task DownstreamForwardAsync([FromBody] PickupOrderIdInput input) { var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + return await DownstreamForwardCoreAsync(pickup); + } + + /// + /// 预报下游核心:回写承运运单号,draft 升为 scheduled。 + /// + private async Task DownstreamForwardCoreAsync(PickupOrder pickup) + { if (string.IsNullOrWhiteSpace(pickup.CarrierService)) { throw new BusinessException("请先选择承运服务后再预报下游"); @@ -974,6 +1205,14 @@ public class PickupOrderService : ApplicationService changed = true; } + // 拿到承运运单号后 draft → scheduled(已预约) + if (!string.IsNullOrWhiteSpace(pickup.CarrierWaybillNo) + && pickup.Status == PickupOrderStatusCodes.Draft) + { + pickup.Status = PickupOrderStatusCodes.Scheduled; + changed = true; + } + if (string.IsNullOrWhiteSpace(pickup.CarrierCode) && !string.IsNullOrWhiteSpace(creds.SupplierCode)) { -- 2.45.2 From ce82c102a4e2aa2a166e77fd4c66b9108eb6ad5b Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Fri, 9 Oct 2026 16:15:59 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E6=A1=86?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 7d6f04d..9c939ac 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 8.0.5.17 + 8.0.5.18 -- 2.45.2 From 2b0158acb4976864525a63508f8f0a79719c8232 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 9 Oct 2026 17:32:08 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E8=BF=90=E5=8D=95=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=97=A5=E5=BF=97=20MVP=EF=BC=8C=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E7=BC=96=E8=BE=91/=E4=BB=B6=E9=87=8D=E5=B0=BA/=E9=87=8D?= =?UTF-8?q?=E7=AE=97/=E6=8E=92=E8=B4=A7=E5=AD=97=E6=AE=B5=E5=8F=98?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- db.sql | 24 ++ sql/drop_all_unique_indexes.sql | 213 +++++++++++++++ sql/migrate_order_operation_log.sql | 42 +++ .../Dtos/Orders/OrderOperationLogDtos.cs | 44 ++++ .../Orders/OrderOperationLogSnapshot.cs | 247 ++++++++++++++++++ .../Orders/OrderOperationLogWriter.cs | 132 ++++++++++ .../Orders/OrderService.OperationLog.cs | 55 ++++ .../Services/Orders/OrderService.OrderEdit.cs | 69 ++++- .../Consts/OrderOperationActionCodes.cs | 20 ++ .../Entities/OrderOperationLog.cs | 36 +++ .../IOrderOperationLogRepository.cs | 8 + .../Orders/OrderOperationLogRepository.cs | 13 + 12 files changed, 896 insertions(+), 7 deletions(-) create mode 100644 sql/drop_all_unique_indexes.sql create mode 100644 sql/migrate_order_operation_log.sql create mode 100644 src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs create mode 100644 src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs create mode 100644 src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.OperationLog.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs create mode 100644 src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs diff --git a/db.sql b/db.sql index f55db57..485e33b 100644 --- a/db.sql +++ b/db.sql @@ -104,6 +104,7 @@ DROP TABLE IF EXISTS `t_order_sender`; DROP TABLE IF EXISTS `t_order_batch_job_item`; DROP TABLE IF EXISTS `t_order_batch_job`; DROP TABLE IF EXISTS `t_order_template`; +DROP TABLE IF EXISTS `t_order_operation_log`; DROP TABLE IF EXISTS `t_order_exception_event`; DROP TABLE IF EXISTS `t_order`; @@ -528,6 +529,29 @@ CREATE TABLE `t_order_exception_event` ( KEY `idx_oee_order_time` (`OrderId`, `CreateTime`) ) COMMENT='订单异常事件表'; +CREATE TABLE `t_order_operation_log` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `OrderId` INT NOT NULL COMMENT '运单Id', + `ServiceNo` VARCHAR(64) NULL COMMENT '运单号冗余', + `Action` VARCHAR(32) NOT NULL COMMENT '动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate', + `ActionName` VARCHAR(64) NOT NULL COMMENT '动作中文名', + `OperatorId` INT NOT NULL DEFAULT 0 COMMENT '操作人用户Id', + `OperatorName` VARCHAR(64) NULL COMMENT '操作人展示名', + `OperatorType` VARCHAR(16) NOT NULL DEFAULT 'staff' COMMENT 'staff/customer/system/openapi', + `Source` VARCHAR(16) NOT NULL DEFAULT 'pc' COMMENT 'pc/h5/openapi/job', + `ChangesJson` JSON NULL COMMENT '字段 diff 数组 [{path,label,before,after}]', + `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_ool_order_time` (`OrderId`, `CreateTime`), + KEY `idx_ool_service_no` (`ServiceNo`) +) COMMENT='运单操作日志'; + CREATE TABLE `t_sales_product` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `ProductCode` VARCHAR(64) NOT NULL COMMENT '产品编码', diff --git a/sql/drop_all_unique_indexes.sql b/sql/drop_all_unique_indexes.sql new file mode 100644 index 0000000..4f8d7cc --- /dev/null +++ b/sql/drop_all_unique_indexes.sql @@ -0,0 +1,213 @@ +-- ============================================================================= +-- drop_all_unique_indexes.sql +-- 去掉当前库所有非 PRIMARY 唯一索引的「唯一性」(改为同名普通 INDEX) +-- 流程:备份并删除外键 → UNIQUE 改 KEY → 恢复外键 +-- 解决 Error 1553:Cannot drop index ... needed in a foreign key constraint +-- 可重复执行;唯一性请在业务代码中校验。 +-- 请在 Navicat / DBeaver / MySQL Workbench 中整段执行(需要 DELIMITER)。 +-- ============================================================================= + +SET @db := DATABASE(); + +-- ---------- 预览:将处理的唯一索引 ---------- +SELECT + TABLE_NAME, + INDEX_NAME, + GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns +FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = @db + AND NON_UNIQUE = 0 + AND INDEX_NAME <> 'PRIMARY' +GROUP BY TABLE_NAME, INDEX_NAME +ORDER BY TABLE_NAME, INDEX_NAME; + +-- ---------- 预览:库中物理外键(会临时删除再重建)---------- +SELECT + rc.CONSTRAINT_NAME, + rc.TABLE_NAME, + rc.REFERENCED_TABLE_NAME, + rc.UPDATE_RULE, + rc.DELETE_RULE +FROM information_schema.REFERENTIAL_CONSTRAINTS rc +WHERE rc.CONSTRAINT_SCHEMA = @db +ORDER BY rc.TABLE_NAME, rc.CONSTRAINT_NAME; + +DROP PROCEDURE IF EXISTS `sp_drop_all_unique_indexes`; +DELIMITER $$ +CREATE PROCEDURE `sp_drop_all_unique_indexes`() +BEGIN + DECLARE done INT DEFAULT 0; + DECLARE v_fk_name VARCHAR(128); + DECLARE v_fk_table VARCHAR(128); + DECLARE v_ref_table VARCHAR(128); + DECLARE v_update VARCHAR(16); + DECLARE v_delete VARCHAR(16); + DECLARE v_cols TEXT; + DECLARE v_ref_cols TEXT; + DECLARE v_table VARCHAR(128); + DECLARE v_index VARCHAR(128); + DECLARE v_index_cols TEXT; + DECLARE v_sql TEXT; + + -- 1) 备份当前库全部外键定义 + DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_backup`; + CREATE TEMPORARY TABLE `_tmp_fk_backup` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `constraint_name` VARCHAR(128) NOT NULL, + `table_name` VARCHAR(128) NOT NULL, + `column_names` TEXT NOT NULL, + `ref_table_name` VARCHAR(128) NOT NULL, + `ref_column_names` TEXT NOT NULL, + `update_rule` VARCHAR(16) NOT NULL, + `delete_rule` VARCHAR(16) NOT NULL + ); + + INSERT INTO `_tmp_fk_backup` ( + `constraint_name`, `table_name`, `column_names`, + `ref_table_name`, `ref_column_names`, `update_rule`, `delete_rule` + ) + SELECT + kcu.CONSTRAINT_NAME, + kcu.TABLE_NAME, + GROUP_CONCAT(CONCAT('`', kcu.COLUMN_NAME, '`') ORDER BY kcu.ORDINAL_POSITION SEPARATOR ','), + kcu.REFERENCED_TABLE_NAME, + GROUP_CONCAT(CONCAT('`', kcu.REFERENCED_COLUMN_NAME, '`') ORDER BY kcu.ORDINAL_POSITION SEPARATOR ','), + rc.UPDATE_RULE, + rc.DELETE_RULE + FROM information_schema.KEY_COLUMN_USAGE kcu + INNER JOIN information_schema.REFERENTIAL_CONSTRAINTS rc + ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND rc.TABLE_NAME = kcu.TABLE_NAME + WHERE kcu.TABLE_SCHEMA = DATABASE() + AND kcu.REFERENCED_TABLE_NAME IS NOT NULL + GROUP BY + kcu.CONSTRAINT_NAME, + kcu.TABLE_NAME, + kcu.REFERENCED_TABLE_NAME, + rc.UPDATE_RULE, + rc.DELETE_RULE; + + -- 2) 删除全部外键(解除对唯一索引的占用) + BEGIN + DECLARE cur_fk_drop CURSOR FOR + SELECT `constraint_name`, `table_name` FROM `_tmp_fk_backup` ORDER BY `id`; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1; + SET done = 0; + OPEN cur_fk_drop; + fk_drop_loop: LOOP + FETCH cur_fk_drop INTO v_fk_name, v_fk_table; + IF done = 1 THEN + LEAVE fk_drop_loop; + END IF; + SET v_sql = CONCAT('ALTER TABLE `', v_fk_table, '` DROP FOREIGN KEY `', v_fk_name, '`'); + SET @ddl := v_sql; + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END LOOP; + CLOSE cur_fk_drop; + END; + + -- 3) 唯一索引 → 同名普通 INDEX(去掉唯一性,保留列与查询能力,便于外键重建) + BEGIN + DECLARE cur_uk CURSOR FOR + SELECT + s.TABLE_NAME, + s.INDEX_NAME, + GROUP_CONCAT(CONCAT('`', s.COLUMN_NAME, '`') ORDER BY s.SEQ_IN_INDEX SEPARATOR ',') + FROM information_schema.STATISTICS s + WHERE s.TABLE_SCHEMA = DATABASE() + AND s.NON_UNIQUE = 0 + AND s.INDEX_NAME <> 'PRIMARY' + GROUP BY s.TABLE_NAME, s.INDEX_NAME + ORDER BY s.TABLE_NAME, s.INDEX_NAME; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1; + SET done = 0; + OPEN cur_uk; + uk_loop: LOOP + FETCH cur_uk INTO v_table, v_index, v_index_cols; + IF done = 1 THEN + LEAVE uk_loop; + END IF; + SET v_sql = CONCAT( + 'ALTER TABLE `', v_table, '` DROP INDEX `', v_index, + '`, ADD INDEX `', v_index, '` (', v_index_cols, ')' + ); + SET @ddl := v_sql; + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END LOOP; + CLOSE cur_uk; + END; + + -- 4) 按备份重建外键 + -- 若原外键引用的是「非主键唯一键」,去掉唯一性后 InnoDB 可能拒绝重建,将跳过并写入警告表 + DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_restore_fail`; + CREATE TEMPORARY TABLE `_tmp_fk_restore_fail` ( + `constraint_name` VARCHAR(128) NOT NULL, + `table_name` VARCHAR(128) NOT NULL, + `error_msg` TEXT + ); + + BEGIN + DECLARE cur_fk_add CURSOR FOR + SELECT + `constraint_name`, `table_name`, `column_names`, + `ref_table_name`, `ref_column_names`, `update_rule`, `delete_rule` + FROM `_tmp_fk_backup` + ORDER BY `id`; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1; + DECLARE CONTINUE HANDLER FOR SQLEXCEPTION + BEGIN + GET DIAGNOSTICS CONDITION 1 @fk_err = MESSAGE_TEXT; + INSERT INTO `_tmp_fk_restore_fail` (`constraint_name`, `table_name`, `error_msg`) + VALUES (v_fk_name, v_fk_table, @fk_err); + END; + SET done = 0; + OPEN cur_fk_add; + fk_add_loop: LOOP + FETCH cur_fk_add INTO + v_fk_name, v_fk_table, v_cols, v_ref_table, v_ref_cols, v_update, v_delete; + IF done = 1 THEN + LEAVE fk_add_loop; + END IF; + SET v_sql = CONCAT( + 'ALTER TABLE `', v_fk_table, + '` ADD CONSTRAINT `', v_fk_name, + '` FOREIGN KEY (', v_cols, + ') REFERENCES `', v_ref_table, '` (', v_ref_cols, + ') ON UPDATE ', v_update, + ' ON DELETE ', v_delete + ); + SET @ddl := v_sql; + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END LOOP; + CLOSE cur_fk_add; + END; + + -- 输出重建失败的外键(通常因引用侧不再是 UNIQUE/PRIMARY) + SELECT * FROM `_tmp_fk_restore_fail`; + + DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_backup`; + DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_restore_fail`; +END$$ +DELIMITER ; + +CALL `sp_drop_all_unique_indexes`(); +DROP PROCEDURE IF EXISTS `sp_drop_all_unique_indexes`; + +-- ---------- 校验:唯一索引应无结果(PRIMARY 除外)---------- +SELECT + TABLE_NAME, + INDEX_NAME, + GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns +FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = @db + AND NON_UNIQUE = 0 + AND INDEX_NAME <> 'PRIMARY' +GROUP BY TABLE_NAME, INDEX_NAME +ORDER BY TABLE_NAME, INDEX_NAME; diff --git a/sql/migrate_order_operation_log.sql b/sql/migrate_order_operation_log.sql new file mode 100644 index 0000000..cd0aba0 --- /dev/null +++ b/sql/migrate_order_operation_log.sql @@ -0,0 +1,42 @@ +-- ============================================================================= +-- migrate_order_operation_log.sql +-- 运单操作日志表(业务动作 + 字段级 before/after diff) +-- 可重复执行 +-- ============================================================================= +SET @db := DATABASE(); + +SET @tbl := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_operation_log' +); +SET @sql := IF( + @tbl = 0, + 'CREATE TABLE `t_order_operation_log` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `OrderId` INT NOT NULL COMMENT ''运单Id'', + `ServiceNo` VARCHAR(64) NULL COMMENT ''运单号冗余'', + `Action` VARCHAR(32) NOT NULL COMMENT ''动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate'', + `ActionName` VARCHAR(64) NOT NULL COMMENT ''动作中文名'', + `OperatorId` INT NOT NULL DEFAULT 0 COMMENT ''操作人用户Id'', + `OperatorName` VARCHAR(64) NULL COMMENT ''操作人展示名'', + `OperatorType` VARCHAR(16) NOT NULL DEFAULT ''staff'' COMMENT ''staff/customer/system/openapi'', + `Source` VARCHAR(16) NOT NULL DEFAULT ''pc'' COMMENT ''pc/h5/openapi/job'', + `ChangesJson` JSON NULL COMMENT ''字段 diff 数组 [{path,label,before,after}]'', + `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_ool_order_time` (`OrderId`, `CreateTime`), + KEY `idx_ool_service_no` (`ServiceNo`) + ) COMMENT=''运单操作日志''', + 'SELECT ''t_order_operation_log already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs b/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs new file mode 100644 index 0000000..3b787e0 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs @@ -0,0 +1,44 @@ +namespace Oms.Application.Dtos.Orders; + +public class OrderOperationLogChangeItem +{ + public string Path { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; + + public string? Before { get; set; } + + public string? After { get; set; } +} + +public class OrderOperationLogOutput +{ + public int Id { get; set; } + + public int OrderId { get; set; } + + public string? ServiceNo { get; set; } + + public string Action { get; set; } = string.Empty; + + public string ActionName { get; set; } = string.Empty; + + public int OperatorId { get; set; } + + public string? OperatorName { get; set; } + + public string OperatorType { get; set; } = string.Empty; + + public string Source { get; set; } = string.Empty; + + public List Changes { get; set; } = []; + + public string? Remark { get; set; } + + public DateTime CreateTime { get; set; } +} + +public class OrderOperationLogListOutput +{ + public List Items { get; set; } = []; +} diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs new file mode 100644 index 0000000..8a3d31c --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs @@ -0,0 +1,247 @@ +using System.Globalization; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +/// +/// 运单操作日志白名单快照与字段 label。 +/// +public static class OrderOperationLogSnapshot +{ + private static readonly Dictionary Labels = new(StringComparer.OrdinalIgnoreCase) + { + ["orderStatus"] = "订单状态", + ["clientNo"] = "客户单号", + ["trackNo"] = "跟踪号", + ["channelCode"] = "服务渠道", + ["productCode"] = "销售产品", + ["warehouseId"] = "仓库", + ["cargoAttribute"] = "货物属性", + ["isCod"] = "是否COD", + ["codCurrency"] = "COD币种", + ["codSum"] = "COD金额", + ["declaredCurrency"] = "申报币种", + ["declaredSum"] = "申报金额", + ["quantity"] = "包裹数量", + ["receivableSubtotal"] = "应收小计", + ["payableAmount"] = "应付金额", + ["downstreamServiceNo"] = "服务商单号", + ["allocationScheme"] = "排货方案", + ["isPaid"] = "是否已支付", + ["sender.name"] = "发件人姓名", + ["sender.phone"] = "发件人电话", + ["sender.country"] = "发件国家", + ["sender.province"] = "发件省/州", + ["sender.city"] = "发件城市", + ["sender.zipCode"] = "发件邮编", + ["sender.address1"] = "发件地址1", + ["sender.address2"] = "发件地址2", + ["receiver.name"] = "收件人姓名", + ["receiver.phone"] = "收件人电话", + ["receiver.country"] = "收件国家", + ["receiver.province"] = "收件省/州", + ["receiver.city"] = "收件城市", + ["receiver.zipCode"] = "收件邮编", + ["receiver.address1"] = "收件地址1", + ["receiver.address2"] = "收件地址2", + ["fee.receivableTotal"] = "应收合计", + ["fee.payableTotal"] = "应付合计", + }; + + public static Dictionary CaptureOrder(Order order) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["orderStatus"] = Norm(order.OrderStatus), + ["clientNo"] = Norm(order.ClientNo), + ["trackNo"] = Norm(order.TrackNo), + ["channelCode"] = Norm(order.ChannelCode), + ["productCode"] = Norm(order.ProductCode), + ["warehouseId"] = FormatInt(order.WarehouseId), + ["cargoAttribute"] = Norm(order.CargoAttribute), + ["isCod"] = FormatByte(order.IsCod), + ["codCurrency"] = Norm(order.CodCurrency), + ["codSum"] = FormatDecimal(order.CodSum), + ["declaredCurrency"] = Norm(order.DeclaredCurrency), + ["declaredSum"] = FormatDecimal(order.DeclaredSum), + ["quantity"] = FormatInt(order.Quantity), + ["receivableSubtotal"] = FormatDecimal(order.ReceivableSubtotal), + ["payableAmount"] = FormatDecimal(order.PayableAmount), + ["downstreamServiceNo"] = Norm(order.DownstreamServiceNo), + ["allocationScheme"] = Norm(order.AllocationScheme), + ["isPaid"] = FormatByte(order.IsPaid), + }; + } + + public static Dictionary CaptureSender(OrderSender? sender) + { + if (sender == null) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sender.name"] = Norm(sender.SenderName), + ["sender.phone"] = Norm(sender.Phone), + ["sender.country"] = FormatInt(sender.Country), + ["sender.province"] = Norm(sender.Province), + ["sender.city"] = Norm(sender.City), + ["sender.zipCode"] = Norm(sender.ZipCode), + ["sender.address1"] = Norm(sender.StreetAddress1), + ["sender.address2"] = Norm(sender.StreetAddress2), + }; + } + + public static Dictionary CaptureReceiver(OrderRecipient? recipient) + { + if (recipient == null) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["receiver.name"] = Norm(recipient.RecipientsName), + ["receiver.phone"] = Norm(recipient.Phone), + ["receiver.country"] = FormatInt(recipient.Country), + ["receiver.province"] = Norm(recipient.Province), + ["receiver.city"] = Norm(recipient.City), + ["receiver.zipCode"] = Norm(recipient.ZipCode), + ["receiver.address1"] = Norm(recipient.StreetAddress1), + ["receiver.address2"] = Norm(recipient.StreetAddress2), + }; + } + + public static Dictionary CaptureWeights(IEnumerable weights) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in weights) + { + var type = string.IsNullOrWhiteSpace(row.Type) ? "unknown" : row.Type.Trim().ToLowerInvariant(); + var prefix = $"weight.{type}"; + map[$"{prefix}.actualWeight"] = FormatDecimal(row.ActualWeight); + map[$"{prefix}.length"] = FormatDecimal(row.Length); + map[$"{prefix}.width"] = FormatDecimal(row.Width); + map[$"{prefix}.height"] = FormatDecimal(row.Height); + map[$"{prefix}.chargeableWeight"] = FormatDecimal(row.ChargeableWeight); + } + + return map; + } + + public static Dictionary CaptureFeeTotals(IEnumerable fees) + { + var list = fees.ToList(); + var receivable = list + .Where(x => string.Equals(x.Type, OrderFeeTypeCodes.Receivable, StringComparison.OrdinalIgnoreCase)) + .Sum(x => x.Amount); + var payable = list + .Where(x => string.Equals(x.Type, OrderFeeTypeCodes.Payable, StringComparison.OrdinalIgnoreCase)) + .Sum(x => x.Amount); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["fee.receivableTotal"] = FormatDecimal(receivable), + ["fee.payableTotal"] = FormatDecimal(payable), + }; + } + + public static Dictionary Merge(params IReadOnlyDictionary[] parts) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in parts) + { + foreach (var (key, value) in part) + { + map[key] = value; + } + } + + return map; + } + + public static List<(string Path, string Label, string? Before, string? After)> Diff( + IReadOnlyDictionary before, + IReadOnlyDictionary after) + { + var keys = before.Keys.Union(after.Keys, StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(); + var changes = new List<(string, string, string?, string?)>(); + foreach (var key in keys) + { + before.TryGetValue(key, out var b); + after.TryGetValue(key, out var a); + b = Norm(b); + a = Norm(a); + if (string.Equals(b, a, StringComparison.Ordinal)) + { + continue; + } + + changes.Add((key, ResolveLabel(key), b, a)); + } + + return changes; + } + + public static string ResolveLabel(string path) + { + if (Labels.TryGetValue(path, out var label)) + { + return label; + } + + if (path.StartsWith("weight.", StringComparison.OrdinalIgnoreCase)) + { + var parts = path.Split('.'); + if (parts.Length >= 3) + { + var typeName = parts[1] switch + { + "forecast" => "预报", + "receive" => "收货", + "ship" => "发货", + "downstream" => "对账", + _ => parts[1] + }; + var fieldName = parts[2] switch + { + "actualWeight" => "实重", + "length" => "长", + "width" => "宽", + "height" => "高", + "chargeableWeight" => "计费重", + _ => parts[2] + }; + return $"{typeName}{fieldName}"; + } + } + + return path; + } + + private static string? Norm(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim(); + } + + private static string? FormatDecimal(decimal? value) + { + if (!value.HasValue) + { + return null; + } + + return value.Value.ToString("0.####", CultureInfo.InvariantCulture); + } + + private static string? FormatInt(int? value) => value.HasValue ? value.Value.ToString(CultureInfo.InvariantCulture) : null; + + private static string FormatByte(byte value) => value == 0 ? "0" : "1"; +} diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs b/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs new file mode 100644 index 0000000..d5b3aea --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs @@ -0,0 +1,132 @@ +using Fake.DependencyInjection; +using Fake.Security.Claims; +using Fake.Users; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Services.Orders; + +/// +/// 运单操作日志写入器:字段 diff 落库;失败不影响主业务。 +/// +public class OrderOperationLogWriter : IScopedDependency +{ + private readonly IOrderOperationLogRepository _repository; + private readonly ICurrentUser _currentUser; + private readonly OmsRbacUserDisplayNameResolver _userDisplayNameResolver; + private readonly ILogger _logger; + + public OrderOperationLogWriter( + IOrderOperationLogRepository repository, + ICurrentUser currentUser, + OmsRbacUserDisplayNameResolver userDisplayNameResolver, + ILogger logger) + { + _repository = repository; + _currentUser = currentUser; + _userDisplayNameResolver = userDisplayNameResolver; + _logger = logger; + } + + public async Task TryWriteAsync( + Order order, + string action, + IReadOnlyDictionary before, + IReadOnlyDictionary after, + string? remark = null) + { + try + { + var changes = OrderOperationLogSnapshot.Diff(before, after); + var operatorId = 0; + string? operatorName = null; + if (_currentUser.IsAuthenticated) + { + operatorId = _currentUser.GetId(); + operatorName = await _userDisplayNameResolver.ResolveAsync(operatorId); + } + + var entity = new OrderOperationLog + { + OrderId = order.Id, + ServiceNo = order.ServiceNo, + Action = action, + ActionName = OrderOperationActionCodes.GetActionName(action), + OperatorId = operatorId, + OperatorName = operatorName, + OperatorType = "staff", + Source = "pc", + ChangesJson = JsonConvert.SerializeObject( + changes.Select(x => new OrderOperationLogChangeItem + { + Path = x.Path, + Label = x.Label, + Before = x.Before, + After = x.After + }).ToList()), + Remark = string.IsNullOrWhiteSpace(remark) ? null : remark.Trim(), + CreateUserId = operatorId, + CreateTime = DateTime.Now + }; + + await _repository.InsertAsync(entity); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "写入运单操作日志失败 OrderId={OrderId} Action={Action}", + order.Id, + action); + } + } + + public async Task ListByOrderIdAsync(int orderId) + { + var rows = await _repository.GetListAsync(x => x.OrderId == orderId); + var items = rows + .OrderByDescending(x => x.CreateTime) + .ThenByDescending(x => x.Id) + .Select(MapOutput) + .ToList(); + return new OrderOperationLogListOutput { Items = items }; + } + + private static OrderOperationLogOutput MapOutput(OrderOperationLog row) + { + var changes = new List(); + if (!string.IsNullOrWhiteSpace(row.ChangesJson)) + { + try + { + changes = JsonConvert.DeserializeObject>(row.ChangesJson) + ?? []; + } + catch + { + changes = []; + } + } + + return new OrderOperationLogOutput + { + Id = row.Id, + OrderId = row.OrderId, + ServiceNo = row.ServiceNo, + Action = row.Action, + ActionName = row.ActionName, + OperatorId = row.OperatorId, + OperatorName = row.OperatorName, + OperatorType = row.OperatorType, + Source = row.Source, + Changes = changes, + Remark = row.Remark, + CreateTime = row.CreateTime + }; + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs b/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs new file mode 100644 index 0000000..eefa146 --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private OrderOperationLogWriter OperationLogWriter => + LazyServiceProvider.GetRequiredService(); + + /// + /// 运单操作日志列表(按时间倒序)。 + /// + [HttpGet("/order/operation-log/list")] + public async Task GetOperationLogListAsync([FromQuery] int orderId) + { + await GetOrderOrThrowAsync(orderId); + return await OperationLogWriter.ListByOrderIdAsync(orderId); + } + + private async Task> CaptureOrderAuditSnapshotAsync( + Order order, + bool includeParties = false, + bool includeWeights = false, + bool includeFees = false) + { + var parts = new List> + { + OrderOperationLogSnapshot.CaptureOrder(order) + }; + + if (includeParties) + { + var senders = await OrderRepository.GetSendersByOrderIdsAsync([order.Id]); + var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); + parts.Add(OrderOperationLogSnapshot.CaptureSender(senders.FirstOrDefault())); + parts.Add(OrderOperationLogSnapshot.CaptureReceiver(recipients.FirstOrDefault())); + } + + if (includeWeights) + { + var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]); + parts.Add(OrderOperationLogSnapshot.CaptureWeights(weights)); + } + + if (includeFees) + { + var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); + parts.Add(OrderOperationLogSnapshot.CaptureFeeTotals(fees)); + } + + return OrderOperationLogSnapshot.Merge(parts.ToArray()); + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 616c9f0..b0e9653 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -28,6 +28,7 @@ public partial class OrderService public async Task UpdateOrderDetailAsync(OrderUpdateInput input) { var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeParties: true); if (input.ClientNo != null) { @@ -134,6 +135,16 @@ public partial class OrderService input.Items.Select(x => MapDeclarationInput(order.Id, x))); } + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeParties: true); + var remark = input.Items != null ? "申报信息已更新" : null; + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.EditBasic, + beforeSnap, + afterSnap, + remark); + return (await MapOrderOutputsAsync([order])).First(); } @@ -149,6 +160,7 @@ public partial class OrderService } var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true); if (string.IsNullOrWhiteSpace(order.ProductCode)) { throw new BusinessException("订单未绑定销售产品,无法重算计费重"); @@ -224,6 +236,14 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.EditWeight, + beforeSnap, + afterSnap); + return (await MapOrderOutputsAsync([order])).First(); } @@ -234,8 +254,16 @@ public partial class OrderService public async Task RecalculateReceivableFeesAsync(OrderActionInput input) { var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); EnsureReceivableMutable(order); await RecalculateReceivableCoreAsync(order); + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.RecalcReceivable, + beforeSnap, + afterSnap); return (await MapOrderOutputsAsync([order])).First(); } @@ -246,7 +274,15 @@ public partial class OrderService public async Task RecalculatePayableFeesAsync(OrderActionInput input) { var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); await RecalculatePayableCoreAsync(order); + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.RecalcPayable, + beforeSnap, + afterSnap); return (await MapOrderOutputsAsync([order])).First(); } @@ -566,8 +602,17 @@ public partial class OrderService public async Task ReallocateAsync([FromBody] OrderReallocateInput input) { var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); await ReallocateCoreAsync(order, input.Scheme, input.ServiceChannelId); order = await GetOrderOrThrowAsync(input.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + var remark = string.IsNullOrWhiteSpace(input.Scheme) ? null : $"scheme={input.Scheme.Trim()}"; + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.Reallocate, + beforeSnap, + afterSnap, + remark); return (await MapOrderOutputsAsync([order])).First(); } @@ -580,12 +625,22 @@ public partial class OrderService NormalizeReallocateOptions(input.Scheme, input.ServiceChannelId, out var scheme, out var channelId); var result = new OrderBatchActionResultOutput(); var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList(); + var remark = string.IsNullOrWhiteSpace(scheme) ? null : $"scheme={scheme}"; foreach (var orderId in orderIds) { try { var order = await GetOrderOrThrowAsync(orderId); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); await ReallocateCoreAsync(order, scheme, channelId); + order = await GetOrderOrThrowAsync(orderId); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.Reallocate, + beforeSnap, + afterSnap, + remark); result.SuccessCount++; } catch (Exception ex) @@ -990,15 +1045,15 @@ public partial class OrderService private static void EnsureReallocateAllowed(Order order) { - if (order.IsPaid != 0) - { - throw new BusinessException("订单已付款,不允许重新排货"); - } - - if (order.OrderStatus is not (OrderStatusCodes.PendingAllocation or OrderStatusCodes.PendingPayment)) + // 已付款也可排货:只重算应付与服务渠道,不改应收。 + if (order.OrderStatus is not ( + OrderStatusCodes.Predeclared + or OrderStatusCodes.PendingAllocation + or OrderStatusCodes.PendingPayment + or OrderStatusCodes.PendingShip)) { throw new BusinessException( - $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅待排货/待支付可重新排货"); + $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅已预报/待排货/待支付/待发货可重新排货"); } } diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs new file mode 100644 index 0000000..866ac0d --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs @@ -0,0 +1,20 @@ +namespace Oms.Domain.OrdersAggregate.Consts; + +public static class OrderOperationActionCodes +{ + public const string EditBasic = "editBasic"; + public const string EditWeight = "editWeight"; + public const string RecalcReceivable = "recalcReceivable"; + public const string RecalcPayable = "recalcPayable"; + public const string Reallocate = "reallocate"; + + public static string GetActionName(string action) => action switch + { + EditBasic => "编辑订单详情", + EditWeight => "修改件重尺", + RecalcReceivable => "重算应收", + RecalcPayable => "重算应付", + Reallocate => "重新排货", + _ => action + }; +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs new file mode 100644 index 0000000..703354b --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs @@ -0,0 +1,36 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.OrdersAggregate.Entities; + +[SugarTable("t_order_operation_log")] +public class OrderOperationLog : OmsAuditedAggregateRoot +{ + public int OrderId { get; set; } + + [SugarColumn(Length = 64, IsNullable = true)] + public string? ServiceNo { get; set; } + + [SugarColumn(Length = 32)] + public string Action { get; set; } = string.Empty; + + [SugarColumn(Length = 64)] + public string ActionName { get; set; } = string.Empty; + + public int OperatorId { get; set; } + + [SugarColumn(Length = 64, IsNullable = true)] + public string? OperatorName { get; set; } + + [SugarColumn(Length = 16)] + public string OperatorType { get; set; } = "staff"; + + [SugarColumn(Length = 16)] + public string Source { get; set; } = "pc"; + + [SugarColumn(ColumnDataType = "json", IsNullable = true)] + public string? ChangesJson { get; set; } + + [SugarColumn(Length = 512, IsNullable = true)] + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs new file mode 100644 index 0000000..b1321bf --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs @@ -0,0 +1,8 @@ +using Fake.Domain.Repositories; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Domain.OrdersAggregate.Repositories; + +public interface IOrderOperationLogRepository : IRepository +{ +} diff --git a/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs new file mode 100644 index 0000000..c020ea9 --- /dev/null +++ b/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs @@ -0,0 +1,13 @@ +using Fake.DependencyInjection; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Orders; + +public class OrderOperationLogRepository + : OmsSqlSugarRepository, + IOrderOperationLogRepository, + IScopedDependency +{ +} -- 2.45.2 From da27fa4377790835d1e191038e9e240b2177aa0a Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 9 Oct 2026 18:16:38 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=E9=87=8D=E7=AE=97=E5=BA=94?= =?UTF-8?q?=E4=BB=98=E6=94=BE=E8=A1=8C=E9=94=81=E6=B8=A0=E9=81=93=E6=B1=A0?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=8C=E6=94=AF=E6=8C=81=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC/=E8=A7=84=E5=88=99=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E4=B8=8E=E6=9C=8D=E5=8A=A1=E5=95=86=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- db.sql | 1 + sql/migrate_20261009_order_warehouse.sql | 105 ++++++++++++++ ...migrate_order_downstream_supplier_name.sql | 22 +++ .../Orders/OrderBindSnapshotVersionsDtos.cs | 34 +++++ .../Dtos/Orders/OrderOutput.cs | 15 ++ .../Orders/OrderOperationLogSnapshot.cs | 4 + .../Services/Orders/OrderService.Helpers.cs | 8 ++ .../Services/Orders/OrderService.OrderEdit.cs | 131 +++++++++++++++++- .../Orders/OrderService.ParcelBilling.cs | 87 +++++++++--- .../AllocationProviderSnapshotEnricher.cs | 3 + .../Consts/OrderOperationActionCodes.cs | 2 + .../OrdersAggregate/Entities/Order.cs | 6 + 12 files changed, 394 insertions(+), 24 deletions(-) create mode 100644 sql/migrate_20261009_order_warehouse.sql create mode 100644 sql/migrate_order_downstream_supplier_name.sql create mode 100644 src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs diff --git a/db.sql b/db.sql index 485e33b..3f1111e 100644 --- a/db.sql +++ b/db.sql @@ -118,6 +118,7 @@ CREATE TABLE `t_order` ( `ProductCode` VARCHAR(128) NULL COMMENT '产品编码', `ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码', `DownstreamSupplierCode` VARCHAR(64) NULL COMMENT '下游 API 服务商编码 supplierCode', + `DownstreamSupplierName` VARCHAR(128) NULL COMMENT '下游 API 服务商名称', `DownstreamAppKey` VARCHAR(128) NULL COMMENT '下游 itfx 应用 AppKey', `DownstreamAppId` INT NULL COMMENT '下游 itfx 应用 Id', `ChannelForecastCode` VARCHAR(128) NULL COMMENT '渠道预报编码 ForecastCode', diff --git a/sql/migrate_20261009_order_warehouse.sql b/sql/migrate_20261009_order_warehouse.sql new file mode 100644 index 0000000..c8c2b84 --- /dev/null +++ b/sql/migrate_20261009_order_warehouse.sql @@ -0,0 +1,105 @@ +-- ============================================================================= +-- migrate_20261009_order_warehouse.sql +-- 汇总: +-- 1) t_order.DownstreamSupplierName(下游服务商名称) +-- 2) t_order_operation_log(运单操作日志) +-- 3) t_warehouse_address.Sort / Remark(仓库地址排序与备注) +-- 可重复执行。 +-- ============================================================================= + +SET @db := DATABASE(); + +-- ----------------------------------------------------------------------------- +-- 1) 订单:下游服务商名称 +-- ----------------------------------------------------------------------------- +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order' + AND COLUMN_NAME = 'DownstreamSupplierName' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `DownstreamSupplierName` VARCHAR(128) NULL COMMENT ''下游 API 服务商名称'' AFTER `DownstreamSupplierCode`', + 'SELECT ''t_order.DownstreamSupplierName already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 2) 运单操作日志表 +-- ----------------------------------------------------------------------------- +SET @tbl := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_operation_log' +); +SET @sql := IF( + @tbl = 0, + 'CREATE TABLE `t_order_operation_log` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `OrderId` INT NOT NULL COMMENT ''运单Id'', + `ServiceNo` VARCHAR(64) NULL COMMENT ''运单号冗余'', + `Action` VARCHAR(32) NOT NULL COMMENT ''动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate'', + `ActionName` VARCHAR(64) NOT NULL COMMENT ''动作中文名'', + `OperatorId` INT NOT NULL DEFAULT 0 COMMENT ''操作人用户Id'', + `OperatorName` VARCHAR(64) NULL COMMENT ''操作人展示名'', + `OperatorType` VARCHAR(16) NOT NULL DEFAULT ''staff'' COMMENT ''staff/customer/system/openapi'', + `Source` VARCHAR(16) NOT NULL DEFAULT ''pc'' COMMENT ''pc/h5/openapi/job'', + `ChangesJson` JSON NULL COMMENT ''字段 diff 数组 [{path,label,before,after}]'', + `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_ool_order_time` (`OrderId`, `CreateTime`), + KEY `idx_ool_service_no` (`ServiceNo`) + ) COMMENT=''运单操作日志''', + 'SELECT ''t_order_operation_log already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 3) 仓库地址:展示排序 + 备注 +-- ----------------------------------------------------------------------------- +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_warehouse_address' + AND COLUMN_NAME = 'Sort' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Sort` INT NULL DEFAULT NULL COMMENT ''展示排序,越小越靠前'' AFTER `ShippingAddress`', + 'SELECT ''t_warehouse_address.Sort 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_warehouse_address' + AND COLUMN_NAME = 'Remark' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Remark` VARCHAR(512) NULL DEFAULT NULL COMMENT ''备注'' AFTER `Sort`', + 'SELECT ''t_warehouse_address.Remark already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_order_downstream_supplier_name.sql b/sql/migrate_order_downstream_supplier_name.sql new file mode 100644 index 0000000..f3ba5ad --- /dev/null +++ b/sql/migrate_order_downstream_supplier_name.sql @@ -0,0 +1,22 @@ +-- 订单增加下游服务商名称(排货确认时写入 itfx 服务商名 / 分组名)。 +-- 可重复执行。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order' + AND COLUMN_NAME = 'DownstreamSupplierName' +); + +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `DownstreamSupplierName` VARCHAR(128) NULL COMMENT ''下游 API 服务商名称'' AFTER `DownstreamSupplierCode`', + 'SELECT ''t_order.DownstreamSupplierName already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs b/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs new file mode 100644 index 0000000..2abfce4 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 切换订单绑定的销售产品规则/价格发布版本。 +/// +public class OrderBindSnapshotVersionsInput +{ + [Required] + public int Id { get; set; } + + /// + /// 目标规则版本;空表示不改。特殊值 CURRENT 表示绑定当前有效规则发布。 + /// + [StringLength(64)] + public string? RulesVersion { get; set; } + + /// + /// 目标价格版本;空表示不改。特殊值 CURRENT 表示绑定当前有效价格发布。 + /// + [StringLength(64)] + public string? PriceVersion { get; set; } + + /// + /// 绑定后是否立即重算应收(已支付会拒绝)。 + /// + public bool RecalculateReceivable { get; set; } + + /// + /// 绑定后是否立即重算应付(锁定当前渠道)。 + /// + public bool RecalculatePayable { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs index a82aea9..4eaed1f 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs @@ -55,6 +55,11 @@ public class OrderOutput /// public string? DownstreamSupplierCode { get; set; } + /// + /// 下游 API 服务商名称。 + /// + public string? DownstreamSupplierName { get; set; } + /// /// 下游 itfx 应用 AppKey。 /// @@ -363,6 +368,16 @@ public class OrderOutput /// public decimal? ForecastPayableAmount { get; set; } + /// + /// 订单绑定的销售产品规则发布版本。 + /// + public string? SalesProductRulesVersion { get; set; } + + /// + /// 订单绑定的销售产品价格发布版本。 + /// + public string? SalesProductPriceVersion { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs index 8a3d31c..527471a 100644 --- a/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs +++ b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs @@ -28,6 +28,8 @@ public static class OrderOperationLogSnapshot ["payableAmount"] = "应付金额", ["downstreamServiceNo"] = "服务商单号", ["allocationScheme"] = "排货方案", + ["salesProductRulesVersion"] = "规则版本", + ["salesProductPriceVersion"] = "价格版本", ["isPaid"] = "是否已支付", ["sender.name"] = "发件人姓名", ["sender.phone"] = "发件人电话", @@ -70,6 +72,8 @@ public static class OrderOperationLogSnapshot ["payableAmount"] = FormatDecimal(order.PayableAmount), ["downstreamServiceNo"] = Norm(order.DownstreamServiceNo), ["allocationScheme"] = Norm(order.AllocationScheme), + ["salesProductRulesVersion"] = Norm(order.SalesProductRulesVersion), + ["salesProductPriceVersion"] = Norm(order.SalesProductPriceVersion), ["isPaid"] = FormatByte(order.IsPaid), }; } diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index c5d6e34..40f81fa 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -165,6 +165,7 @@ public partial class OrderService MaxTimeliness = product?.MaxTimeliness, ChannelCode = order.ChannelCode, DownstreamSupplierCode = order.DownstreamSupplierCode, + DownstreamSupplierName = order.DownstreamSupplierName, DownstreamAppKey = order.DownstreamAppKey, DownstreamAppId = order.DownstreamAppId, ChannelForecastCode = order.ChannelForecastCode, @@ -262,6 +263,12 @@ public partial class OrderService HasInsurance = hasInsurance, ForecastReceivableAmount = order.ForecastReceivableAmount, ForecastPayableAmount = order.ForecastPayableAmount, + SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion) + ? null + : order.SalesProductRulesVersion.Trim(), + SalesProductPriceVersion = string.IsNullOrWhiteSpace(order.SalesProductPriceVersion) + ? null + : order.SalesProductPriceVersion.Trim(), CreateTime = order.CreateTime, CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.CreateUserId, auditUserNameMap), UpdateTime = OmsDocHelper.NullIfDefault(order.UpdateTime), @@ -387,6 +394,7 @@ public partial class OrderService output.ChannelCode = null; output.ChannelName = null; output.DownstreamSupplierCode = null; + output.DownstreamSupplierName = null; output.DownstreamAppKey = null; output.DownstreamAppId = null; output.ChannelForecastCode = null; diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index b0e9653..9049e59 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -286,6 +286,127 @@ public partial class OrderService return (await MapOrderOutputsAsync([order])).First(); } + /// + /// 切换订单绑定的销售产品规则/价格发布版本(显式改契约后再重算)。 + /// + [HttpPost("/order/fee/bind-snapshot-versions")] + public async Task BindSnapshotVersionsAsync([FromBody] OrderBindSnapshotVersionsInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + + if (string.IsNullOrWhiteSpace(order.ProductCode)) + { + throw new BusinessException("订单未绑定销售产品,无法切换版本"); + } + + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product == null) + { + throw new BusinessException("销售产品不存在"); + } + + var wantRules = !string.IsNullOrWhiteSpace(input.RulesVersion); + var wantPrice = !string.IsNullOrWhiteSpace(input.PriceVersion); + if (!wantRules && !wantPrice) + { + throw new BusinessException("请至少指定规则版本或价格版本"); + } + + var remarkParts = new List(); + if (wantRules) + { + var resolved = await ResolveBindTargetVersionAsync( + product, + RulesOperatorType, + input.RulesVersion!.Trim(), + "规则"); + if (!string.Equals(order.SalesProductRulesVersion, resolved, StringComparison.Ordinal)) + { + remarkParts.Add($"rules:{order.SalesProductRulesVersion ?? "-"}→{resolved}"); + order.SalesProductRulesVersion = resolved; + } + } + + if (wantPrice) + { + var resolved = await ResolveBindTargetVersionAsync( + product, + PriceOperatorType, + input.PriceVersion!.Trim(), + "价格"); + if (!string.Equals(order.SalesProductPriceVersion, resolved, StringComparison.Ordinal)) + { + remarkParts.Add($"price:{order.SalesProductPriceVersion ?? "-"}→{resolved}"); + order.SalesProductPriceVersion = resolved; + } + } + + if (remarkParts.Count == 0 && !input.RecalculateReceivable && !input.RecalculatePayable) + { + throw new BusinessException("目标版本与当前绑定相同,且未勾选重算"); + } + + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + + if (input.RecalculateReceivable) + { + EnsureReceivableMutable(order); + await RecalculateReceivableCoreAsync(order); + } + + if (input.RecalculatePayable) + { + await RecalculatePayableCoreAsync(order); + } + + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.BindSnapshotVersions, + beforeSnap, + afterSnap, + remarkParts.Count == 0 ? null : string.Join(';', remarkParts)); + return (await MapOrderOutputsAsync([order])).First(); + } + + /// + /// 解析绑定目标版本:CURRENT 取当前有效发布,否则按版本号校验存在。 + /// + private async Task ResolveBindTargetVersionAsync( + SalesProduct product, + string operatorType, + string versionOrCurrent, + string label) + { + if (string.Equals(versionOrCurrent, "CURRENT", StringComparison.OrdinalIgnoreCase)) + { + var effective = await SalesProductRepository.GetEffectivePublishRecordAsync( + product.Id, + operatorType, + DateTime.Now); + if (effective == null || string.IsNullOrWhiteSpace(effective.Version)) + { + throw new BusinessException($"未命中当前有效{label}发布版本"); + } + + return effective.Version.Trim(); + } + + var record = await SalesProductRepository.GetPublishRecordByVersionAsync( + product.Id, + operatorType, + versionOrCurrent); + if (record == null || string.IsNullOrWhiteSpace(record.Version)) + { + throw new BusinessException($"未找到{label}发布版本:{versionOrCurrent}"); + } + + return record.Version.Trim(); + } + /// /// 重跑入库计费(应收 wipe+upsert + 排货应付)。用于队列投递失败或待排货补救。 /// @@ -1010,6 +1131,7 @@ public partial class OrderService // 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。 // 对账重算 / 普通重算应付:沿用订单快照绑定(分区等),买价仍由排货评估取当前有效发布。 + // 锁渠道重算应付:允许渠道不在旧规则排货池内(渠道已确认,只重算买价)。 var snapshotBinding = enforceReallocateGate ? await ResolveSnapshotBindingAsync(product, DateTime.Now) : await ResolveSnapshotBindingFromOrderAsync(product, order); @@ -1032,7 +1154,14 @@ public partial class OrderService billing, chargingStage, schemeOverride, - forceChannelId); + forceChannelId, + allowDesignatedOutsidePool: lockCurrentChannel); + + // 重新排货成功后写回规则版本,使后续重算应付与当前排货池一致;卖价版本保持契约,不随排货改写。 + if (enforceReallocateGate) + { + order.SalesProductRulesVersion = snapshotBinding.RulesVersion; + } if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 2fd317d..8f7bc82 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -848,7 +848,8 @@ public partial class OrderService string chargingStage, string? _, string? schemeOverride = null, - int? forceServiceChannelId = null) + int? forceServiceChannelId = null, + bool allowDesignatedOutsidePool = false) { var evaluation = await EvaluateAllocationAsync( order, @@ -858,7 +859,8 @@ public partial class OrderService chargeableWeight, chargingStage, schemeOverride, - forceServiceChannelId); + forceServiceChannelId, + allowDesignatedOutsidePool); ApplyAllocationTraceToOrder(order, evaluation); @@ -928,7 +930,8 @@ public partial class OrderService decimal chargeableWeight, string chargingStage, string? schemeOverride, - int? forceServiceChannelId) + int? forceServiceChannelId, + bool allowDesignatedOutsidePool = false) { var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules); @@ -985,27 +988,61 @@ public partial class OrderService if (designated.Count == 0) { var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value); - var failReason = poolRow == null - ? "指定渠道不在该销售产品排货池" - : "指定渠道不满足判断条件"; - if (poolRow != null - && skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value)) + // 重算应付锁定已确认渠道:渠道已在单上,不再用旧规则排货池/判断条件拦截,只重算买价。 + if (allowDesignatedOutsidePool) { - skipped.Add(new OrderAllocationSkippedOutput + var lockedChannel = await ServiceChannelRepository.FirstOrDefaultAsync(x => + x.Id == forceServiceChannelId.Value); + if (lockedChannel == null) { - ServiceChannelId = forceServiceChannelId.Value, - ChannelCode = poolRow.ChannelCode, - ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName, - Stage = AllocationResolver.SkipStageCondition, - MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition), - Reason = failReason - }); + return BaseEval("订单服务渠道不存在,无法重算应付"); + } + + var lockedProvider = poolRow ?? new SalesProductAllocationProviderSnapshotDto + { + SalesProductId = product.Id, + ServiceChannelId = lockedChannel.Id, + ServiceChannelName = lockedChannel.ChannelName, + ProviderName = lockedChannel.ChannelName ?? lockedChannel.ChannelCode, + ChannelCode = lockedChannel.ChannelCode, + Weight = 0, + CustomerIds = [], + MatchCondition = null + }; + if (poolRow == null + || AllocationProviderSnapshotEnricher.NeedsDownstreamEnrichment(lockedProvider)) + { + await AllocationProviderSnapshotEnricher.EnrichAsync([lockedProvider]); + } + + filtered = [lockedProvider]; } + else + { + var failReason = poolRow == null + ? "指定渠道不在该销售产品排货池" + : "指定渠道不满足判断条件"; + if (poolRow != null + && skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value)) + { + skipped.Add(new OrderAllocationSkippedOutput + { + ServiceChannelId = forceServiceChannelId.Value, + ChannelCode = poolRow.ChannelCode, + ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName, + Stage = AllocationResolver.SkipStageCondition, + MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition), + Reason = failReason + }); + } - return BaseEval(failReason); + return BaseEval(failReason); + } + } + else + { + filtered = designated; } - - filtered = designated; } if (filtered.Count == 0) @@ -1304,7 +1341,8 @@ public partial class OrderService OrderBillingContext billing, string chargingStage, string? schemeOverride = null, - int? forceServiceChannelId = null) => + int? forceServiceChannelId = null, + bool allowDesignatedOutsidePool = false) => ConfirmServiceChannelAndPayableAsync( order, product, @@ -1314,7 +1352,8 @@ public partial class OrderService chargingStage, billing.PriceTierKey, schemeOverride, - forceServiceChannelId); + forceServiceChannelId, + allowDesignatedOutsidePool); /// /// 尝试排货并写入应付;失败时不抛异常,写入 供运营补配置。 @@ -1325,7 +1364,8 @@ public partial class OrderService OrderBillingContext billing, string chargingStage, string? schemeOverride = null, - int? forceServiceChannelId = null) + int? forceServiceChannelId = null, + bool allowDesignatedOutsidePool = false) { try { @@ -1335,7 +1375,8 @@ public partial class OrderService billing, chargingStage, schemeOverride, - forceServiceChannelId); + forceServiceChannelId, + allowDesignatedOutsidePool); return true; } catch (BusinessException ex) diff --git a/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs b/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs index afb1ef4..4073363 100644 --- a/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs +++ b/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs @@ -108,6 +108,9 @@ public class AllocationProviderSnapshotEnricher( ServiceChannel? channelFallback = null) { order.DownstreamSupplierCode = NullIfEmpty(provider.SupplierCode); + order.DownstreamSupplierName = NullIfEmpty(provider.ApiServiceProviderName) + ?? NullIfEmpty(provider.GroupName) + ?? NullIfEmpty(provider.ProviderName); order.DownstreamAppKey = NullIfEmpty(provider.AppKey); order.DownstreamAppId = provider.AppId; order.ChannelForecastCode = NullIfEmpty(provider.ForecastCode) diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs index 866ac0d..17d6f8b 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs @@ -7,6 +7,7 @@ public static class OrderOperationActionCodes public const string RecalcReceivable = "recalcReceivable"; public const string RecalcPayable = "recalcPayable"; public const string Reallocate = "reallocate"; + public const string BindSnapshotVersions = "bindSnapshotVersions"; public static string GetActionName(string action) => action switch { @@ -15,6 +16,7 @@ public static class OrderOperationActionCodes RecalcReceivable => "重算应收", RecalcPayable => "重算应付", Reallocate => "重新排货", + BindSnapshotVersions => "切换价格/规则版本", _ => action }; } diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index 3aea651..2f2b77e 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -29,6 +29,12 @@ public class Order : OmsAuditedAggregateRoot /// public string? DownstreamSupplierCode { get; set; } + /// + /// 下游 API 服务商名称(itfx service provider name)。 + /// + [SugarColumn(Length = 128, IsNullable = true)] + public string? DownstreamSupplierName { get; set; } + /// /// 下游 itfx 应用 AppKey。 /// -- 2.45.2