diff --git a/db.sql b/db.sql index 3b2a822..ca9a7d5 100644 --- a/db.sql +++ b/db.sql @@ -133,6 +133,9 @@ CREATE TABLE `t_order` ( `PreviousOrderStatus` VARCHAR(32) NULL COMMENT '进入异常状态前的订单状态', `WarehouseId` INT NULL COMMENT '仓库ID', `CustomerId` INT NULL COMMENT '客户ID', + `OrderSource` VARCHAR(32) NULL COMMENT '订单来源:manual/pickup/upload/batch/platform', + `PickupTime` DATETIME NULL COMMENT '预约揽收时间', + `CustomerSenderAddressId` INT NULL COMMENT '发件地址簿 Id', `OrganizationId` CHAR(36) NULL COMMENT '数据归属组织', `SecretLevel` TINYINT NOT NULL DEFAULT 2 COMMENT '数据密级 1-4', `SenderId` INT NULL COMMENT '发件人ID', @@ -168,9 +171,39 @@ CREATE TABLE `t_order` ( KEY `idx_ord_sender` (`SenderId`), KEY `idx_ord_recipient` (`RecipientId`), KEY `idx_ord_declaration` (`DeclarationId`), - KEY `idx_ord_org_secret_ctime` (`OrganizationId`, `SecretLevel`, `CreateTime`) + KEY `idx_ord_org_secret_ctime` (`OrganizationId`, `SecretLevel`, `CreateTime`), + KEY `idx_ord_source_ctime` (`OrderSource`, `CreateTime`) ) COMMENT='订单表'; +CREATE TABLE `t_customer_sender_address` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id', + `AddressName` VARCHAR(64) NULL COMMENT '地址别名', + `SenderName` VARCHAR(50) NOT NULL COMMENT '发件人姓名', + `Company` VARCHAR(255) NULL COMMENT '公司', + `Country` INT NULL COMMENT '国家 t_country.Id', + `Province` VARCHAR(50) NULL COMMENT '州省', + `City` VARCHAR(50) NULL COMMENT '城市', + `ZipCode` VARCHAR(50) NULL COMMENT '邮编', + `StreetAddress1` VARCHAR(500) NOT NULL COMMENT '地址1', + `StreetAddress2` VARCHAR(500) NULL COMMENT '地址2', + `StreetAddress3` VARCHAR(500) NULL COMMENT '地址3', + `HouseNumber` VARCHAR(50) NULL COMMENT '门牌号', + `Phone` VARCHAR(50) NULL COMMENT '手机', + `TelePhone` VARCHAR(50) NULL COMMENT '电话', + `Email` VARCHAR(60) NULL COMMENT '邮箱', + `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认:0-否,1-是', + `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_csa_customer` (`CustomerId`, `IsDeleted`, `Id`), + KEY `idx_csa_customer_default` (`CustomerId`, `IsDefault`) +) COMMENT='客户发件地址簿'; + CREATE TABLE `t_order_template` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Name` VARCHAR(64) NOT NULL COMMENT '模板名称', diff --git a/sql/migrate_t_customer_sender_address.sql b/sql/migrate_t_customer_sender_address.sql new file mode 100644 index 0000000..d3815a7 --- /dev/null +++ b/sql/migrate_t_customer_sender_address.sql @@ -0,0 +1,32 @@ +-- 已有库增量:客户发件地址簿 t_customer_sender_address +-- 对应功能:客户 → 发件地址;揽收订单选址;见 oms系统设计-050801.md §4.2.10 +-- 可重复执行(IF NOT EXISTS) + +CREATE TABLE IF NOT EXISTS `t_customer_sender_address` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id', + `AddressName` VARCHAR(64) NULL COMMENT '地址别名', + `SenderName` VARCHAR(50) NOT NULL COMMENT '发件人姓名', + `Company` VARCHAR(255) NULL COMMENT '公司', + `Country` INT NULL COMMENT '国家 t_country.Id', + `Province` VARCHAR(50) NULL COMMENT '州省', + `City` VARCHAR(50) NULL COMMENT '城市', + `ZipCode` VARCHAR(50) NULL COMMENT '邮编', + `StreetAddress1` VARCHAR(500) NOT NULL COMMENT '地址1', + `StreetAddress2` VARCHAR(500) NULL COMMENT '地址2', + `StreetAddress3` VARCHAR(500) NULL COMMENT '地址3', + `HouseNumber` VARCHAR(50) NULL COMMENT '门牌号', + `Phone` VARCHAR(50) NULL COMMENT '手机', + `TelePhone` VARCHAR(50) NULL COMMENT '电话', + `Email` VARCHAR(60) NULL COMMENT '邮箱', + `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认:0-否,1-是', + `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_csa_customer` (`CustomerId`, `IsDeleted`, `Id`), + KEY `idx_csa_customer_default` (`CustomerId`, `IsDefault`) +) COMMENT='客户发件地址簿'; diff --git a/sql/migrate_t_order_pickup_fields.sql b/sql/migrate_t_order_pickup_fields.sql new file mode 100644 index 0000000..896b0d2 --- /dev/null +++ b/sql/migrate_t_order_pickup_fields.sql @@ -0,0 +1,75 @@ +-- 已有库增量:t_order 揽收订单字段 OrderSource / PickupTime / CustomerSenderAddressId +-- 对应功能:订单 → 揽收订单;见 oms系统设计-050801.md §4.2.1 / §4.4.4 +-- 可重复执行 + +SET @col_exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 't_order' + AND COLUMN_NAME = 'OrderSource' +); + +SET @sql := IF( + @col_exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `OrderSource` VARCHAR(32) NULL COMMENT ''订单来源:manual/pickup/upload/batch/platform'' AFTER `CustomerId`', + 'SELECT ''t_order.OrderSource already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 't_order' + AND COLUMN_NAME = 'PickupTime' +); + +SET @sql := IF( + @col_exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `PickupTime` DATETIME NULL COMMENT ''预约揽收时间'' AFTER `OrderSource`', + 'SELECT ''t_order.PickupTime already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 't_order' + AND COLUMN_NAME = 'CustomerSenderAddressId' +); + +SET @sql := IF( + @col_exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `CustomerSenderAddressId` INT NULL COMMENT ''发件地址簿 Id'' AFTER `PickupTime`', + 'SELECT ''t_order.CustomerSenderAddressId already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `t_order` +SET `OrderSource` = 'manual' +WHERE `OrderSource` IS NULL OR `OrderSource` = ''; + +SET @idx_exists := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 't_order' + AND INDEX_NAME = 'idx_ord_source_ctime' +); + +SET @sql := IF( + @idx_exists = 0, + 'ALTER TABLE `t_order` ADD KEY `idx_ord_source_ctime` (`OrderSource`, `CreateTime`)', + 'SELECT ''idx_ord_source_ctime already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Orders/CustomerSenderAddressDtos.cs b/src/Oms.Application/Dtos/Orders/CustomerSenderAddressDtos.cs new file mode 100644 index 0000000..f01632f --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/CustomerSenderAddressDtos.cs @@ -0,0 +1,159 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 客户发件地址分页查询。 +/// +public class CustomerSenderAddressPaginatedInput +{ + [Newtonsoft.Json.JsonProperty("page")] + public int Page { get; set; } = 1; + + [Newtonsoft.Json.JsonProperty("pageSize")] + public int PageSize { get; set; } = 30; + + [Newtonsoft.Json.JsonProperty("keyword")] + public string? Keyword { get; set; } + + [Newtonsoft.Json.JsonProperty("onlyDefault")] + public bool OnlyDefault { get; set; } +} + +/// +/// 新增发件地址。 +/// +public class CreateCustomerSenderAddressInput +{ + [StringLength(64)] + public string? AddressName { get; set; } + + [Required] + [StringLength(50)] + public string SenderName { get; set; } = string.Empty; + + [StringLength(255)] + public string? Company { get; set; } + + [StringLength(8)] + public string? CountryCode { get; set; } + + [StringLength(50)] + public string? Province { get; set; } + + [StringLength(50)] + public string? City { get; set; } + + [StringLength(50)] + public string? ZipCode { get; set; } + + [Required] + [StringLength(500)] + public string StreetAddress1 { get; set; } = string.Empty; + + [StringLength(500)] + public string? StreetAddress2 { get; set; } + + [StringLength(500)] + public string? StreetAddress3 { get; set; } + + [StringLength(50)] + public string? HouseNumber { get; set; } + + [StringLength(50)] + public string? Phone { get; set; } + + [StringLength(50)] + public string? TelePhone { get; set; } + + [StringLength(60)] + public string? Email { get; set; } + + public bool IsDefault { get; set; } +} + +/// +/// 更新发件地址。 +/// +public class UpdateCustomerSenderAddressInput : CreateCustomerSenderAddressInput +{ + [Required] + public int Id { get; set; } +} + +/// +/// 发件地址出参。 +/// +public class CustomerSenderAddressOutput +{ + public int Id { get; set; } + + public int CustomerId { get; set; } + + public string? AddressName { get; set; } + + public string SenderName { get; set; } = string.Empty; + + public string? Company { get; set; } + + public int? Country { get; set; } + + public string? CountryCode { get; set; } + + public string? Province { get; set; } + + public string? City { get; set; } + + public string? ZipCode { get; set; } + + public string StreetAddress1 { get; set; } = string.Empty; + + public string? StreetAddress2 { get; set; } + + public string? StreetAddress3 { get; set; } + + public string? HouseNumber { get; set; } + + public string? Phone { get; set; } + + public string? TelePhone { get; set; } + + public string? Email { get; set; } + + public bool IsDefault { get; set; } + + public DateTime? CreateTime { get; set; } + + public DateTime? UpdateTime { get; set; } +} + +/// +/// 创建揽收订单入参。 +/// +public class OrderPickupCreateInput +{ + public string? RequestId { get; set; } + + public string? ClientNo { get; set; } + + [Required] + public string ProductCode { get; set; } = string.Empty; + + [Required] + public int WarehouseId { get; set; } + + [Required] + public int SenderAddressId { get; set; } + + [Required] + public DateTime PickupTime { get; set; } + + [Required] + [StringLength(200)] + public string CargoName { get; set; } = string.Empty; + + [Required] + public decimal ActualWeight { get; set; } + + public string? CargoAttribute { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderExportInput.cs b/src/Oms.Application/Dtos/Orders/OrderExportInput.cs index 1b757f4..6d8e8e8 100644 --- a/src/Oms.Application/Dtos/Orders/OrderExportInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderExportInput.cs @@ -11,6 +11,12 @@ public class OrderExportInput [Newtonsoft.Json.JsonProperty("status")] public string? Status { get; set; } + /// + /// 订单来源:all / manual / pickup 等。 + /// + [Newtonsoft.Json.JsonProperty("orderSource")] + public string? OrderSource { get; set; } + /// /// 关键词。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs index 2d45a04..6e375d6 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs @@ -95,6 +95,21 @@ public class OrderOutput /// public int? CustomerId { get; set; } + /// + /// 订单来源。 + /// + public string? OrderSource { get; set; } + + /// + /// 预约揽收时间。 + /// + public DateTime? PickupTime { get; set; } + + /// + /// 发件地址簿 Id。 + /// + public int? CustomerSenderAddressId { get; set; } + /// /// 幂等请求标识。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderPaginatedInput.cs b/src/Oms.Application/Dtos/Orders/OrderPaginatedInput.cs index a2075d6..eaf3cf0 100644 --- a/src/Oms.Application/Dtos/Orders/OrderPaginatedInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderPaginatedInput.cs @@ -31,6 +31,13 @@ public class OrderPaginatedInput [global::System.Text.Json.Serialization.JsonPropertyName("handleStatus")] public string? HandleStatus { get; set; } + /// + /// 订单来源:all / manual / pickup / upload / batch / platform。 + /// + [Newtonsoft.Json.JsonProperty("orderSource")] + [global::System.Text.Json.Serialization.JsonPropertyName("orderSource")] + public string? OrderSource { get; set; } + /// /// 关键词。 /// diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 53161ac..bf023c5 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -119,6 +119,11 @@ public partial class OrderService ? warehouse.WarehouseName : null, CustomerId = order.CustomerId, + OrderSource = string.IsNullOrWhiteSpace(order.OrderSource) + ? OrderSourceCodes.Manual + : order.OrderSource, + PickupTime = order.PickupTime, + CustomerSenderAddressId = order.CustomerSenderAddressId, RequestId = order.RequestId, ExportCustomsMode = order.ExportCustomsMode, PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason) diff --git a/src/Oms.Application/Services/Orders/OrderService.Monitor.cs b/src/Oms.Application/Services/Orders/OrderService.Monitor.cs index 6e8396d..dcad38a 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Monitor.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Monitor.cs @@ -276,6 +276,8 @@ public partial class OrderService return new OrderListFilter { Status = filter.Status, + HandleStatus = filter.HandleStatus, + OrderSource = filter.OrderSource, Keyword = filter.Keyword, CreateTimeStart = start, CreateTimeEnd = filter.CreateTimeEnd, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 03f09d4..fd3320a 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -40,9 +40,19 @@ public partial class OrderService private OrderAutoTriggerService OrderAutoTriggerService => LazyServiceProvider.GetRequiredService(); + private sealed class ParcelCreateContext + { + public string? OrderSource { get; init; } + + public DateTime? PickupTime { get; init; } + + public int? CustomerSenderAddressId { get; init; } + } + private async Task CreateParcelCoreAsync( OrderParcelCreateInput input, - int? operatorUserId = null) + int? operatorUserId = null, + ParcelCreateContext? createContext = null) { if (!string.IsNullOrWhiteSpace(input.RequestId)) { @@ -88,7 +98,14 @@ public partial class OrderService country.ChineseName); var serviceNo = await ResolveServiceNoAsync(); - var order = BuildDraftOrderEntity(input, product, country.CountryCode, country.ChineseName, snapshotBinding, serviceNo); + var order = BuildDraftOrderEntity( + input, + product, + country.CountryCode, + country.ChineseName, + snapshotBinding, + serviceNo, + createContext); order = await OrderRepository.InsertAsync(order); var weights = BuildInitialWeightRows(order.Id, input); @@ -1076,8 +1093,13 @@ public partial class OrderService string consigneeCountry, string consigneeCountryName, ProductSnapshotBinding snapshotBinding, - string serviceNo) + string serviceNo, + ParcelCreateContext? createContext = null) { + var orderSource = string.IsNullOrWhiteSpace(createContext?.OrderSource) + ? OrderSourceCodes.Manual + : createContext!.OrderSource!.Trim(); + return new Order { RequestId = input.RequestId, @@ -1092,6 +1114,9 @@ public partial class OrderService ExportCustomsMode = input.ExportCustomsMode, WarehouseId = input.WarehouseId, CustomerId = input.CustomerId, + OrderSource = orderSource, + PickupTime = createContext?.PickupTime, + CustomerSenderAddressId = createContext?.CustomerSenderAddressId, IsCod = input.IsCod ? (byte)1 : (byte)0, CodCurrency = input.CodCurrency, CodSum = input.CodSum, diff --git a/src/Oms.Application/Services/Orders/OrderService.Pickup.cs b/src/Oms.Application/Services/Orders/OrderService.Pickup.cs new file mode 100644 index 0000000..e37a354 --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.Pickup.cs @@ -0,0 +1,84 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + /// + /// 创建揽收订单(组装小包入参后复用创建核心)。 + /// + [HttpPost("/order/pickup/create")] + public async Task CreatePickupOrderAsync([FromBody] OrderPickupCreateInput input) + { + if (input.WarehouseId <= 0) + { + throw new BusinessException("仓库ID不能为空"); + } + + if (input.SenderAddressId <= 0) + { + throw new BusinessException("发件地址不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.ProductCode)) + { + throw new BusinessException("运输方式不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.CargoName)) + { + throw new BusinessException("货物名称不能为空"); + } + + if (input.ActualWeight <= 0) + { + throw new BusinessException("货物重量必须大于0"); + } + + if (input.PickupTime == default) + { + throw new BusinessException("揽收时间不能为空"); + } + + var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(CustomerRepository, CurrentUser); + var senderAddress = await CustomerSenderAddressRepository.GetOwnedAsync(input.SenderAddressId, customer.Id); + if (senderAddress == null) + { + throw new BusinessException("发件地址不存在"); + } + + var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId); + if (warehouse == null) + { + throw new BusinessException("仓库不存在"); + } + + string? senderCountryCode = null; + if (senderAddress.Country.HasValue) + { + var country = await CountryRepository.FirstOrDefaultAsync(x => x.Id == senderAddress.Country.Value); + senderCountryCode = country?.CountryCode; + } + + var parcelInput = PickupOrderParcelBuilder.Build( + input, + senderAddress, + warehouse, + customer.Id, + senderCountryCode); + + return await CreateParcelCoreAsync( + parcelInput, + createContext: new ParcelCreateContext + { + OrderSource = OrderSourceCodes.Pickup, + PickupTime = input.PickupTime, + CustomerSenderAddressId = senderAddress.Id + }); + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.SenderAddress.cs b/src/Oms.Application/Services/Orders/OrderService.SenderAddress.cs new file mode 100644 index 0000000..8f71ce8 --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.SenderAddress.cs @@ -0,0 +1,253 @@ +using System.Linq.Expressions; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Application.Dtos.Shared; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private ICustomerSenderAddressRepository CustomerSenderAddressRepository => + LazyServiceProvider.GetRequiredService(); + + /// + /// 发件地址分页列表。 + /// + [HttpPost("/order/sender-address/list")] + public async Task> GetSenderAddressListAsync( + [FromBody] CustomerSenderAddressPaginatedInput input) + { + var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(CustomerRepository, CurrentUser); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var predicate = BuildSenderAddressPredicate(customer.Id, input); + + var entities = await CustomerSenderAddressRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary + { + [nameof(CustomerSenderAddress.IsDefault)] = false, + [nameof(CustomerSenderAddress.Id)] = false + }); + var total = await CustomerSenderAddressRepository.CountAsync(predicate); + var countryMap = await LoadCountryCodeMapAsync(entities.Select(x => x.Country).Where(x => x.HasValue).Select(x => x!.Value)); + + return new OmsPageResult + { + Items = entities.Select(x => MapSenderAddressOutput(x, countryMap)).ToList(), + Total = total + }; + } + + /// + /// 发件地址详情。 + /// + [HttpGet("/order/sender-address/detail")] + public async Task GetSenderAddressDetailAsync([FromQuery] int id) + { + var entity = await GetOwnedSenderAddressOrThrowAsync(id); + var countryMap = await LoadCountryCodeMapAsync(entity.Country.HasValue ? [entity.Country.Value] : []); + return MapSenderAddressOutput(entity, countryMap); + } + + /// + /// 新增发件地址。 + /// + [HttpPost("/order/sender-address/create")] + public async Task CreateSenderAddressAsync([FromBody] CreateCustomerSenderAddressInput input) + { + var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(CustomerRepository, CurrentUser); + ValidateSenderAddressInput(input); + var countryId = await ResolveSenderCountryIdAsync(input.CountryCode); + + if (input.IsDefault) + { + await CustomerSenderAddressRepository.ClearDefaultAsync(customer.Id); + } + + var entity = new CustomerSenderAddress + { + CustomerId = customer.Id, + AddressName = TrimOrNull(input.AddressName), + SenderName = input.SenderName.Trim(), + Company = TrimOrNull(input.Company), + Country = countryId, + Province = TrimOrNull(input.Province), + City = TrimOrNull(input.City), + ZipCode = TrimOrNull(input.ZipCode), + StreetAddress1 = input.StreetAddress1.Trim(), + StreetAddress2 = TrimOrNull(input.StreetAddress2), + StreetAddress3 = TrimOrNull(input.StreetAddress3), + HouseNumber = TrimOrNull(input.HouseNumber), + Phone = TrimOrNull(input.Phone), + TelePhone = TrimOrNull(input.TelePhone), + Email = TrimOrNull(input.Email), + IsDefault = input.IsDefault ? (byte)1 : (byte)0 + }; + + entity = await CustomerSenderAddressRepository.InsertAsync(entity); + return new { id = entity.Id }; + } + + /// + /// 更新发件地址。 + /// + [HttpPost("/order/sender-address/update")] + public async Task UpdateSenderAddressAsync([FromBody] UpdateCustomerSenderAddressInput input) + { + var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id); + ValidateSenderAddressInput(input); + var countryId = await ResolveSenderCountryIdAsync(input.CountryCode); + + if (input.IsDefault) + { + await CustomerSenderAddressRepository.ClearDefaultAsync(entity.CustomerId, entity.Id); + } + + entity.AddressName = TrimOrNull(input.AddressName); + entity.SenderName = input.SenderName.Trim(); + entity.Company = TrimOrNull(input.Company); + entity.Country = countryId; + entity.Province = TrimOrNull(input.Province); + entity.City = TrimOrNull(input.City); + entity.ZipCode = TrimOrNull(input.ZipCode); + entity.StreetAddress1 = input.StreetAddress1.Trim(); + entity.StreetAddress2 = TrimOrNull(input.StreetAddress2); + entity.StreetAddress3 = TrimOrNull(input.StreetAddress3); + entity.HouseNumber = TrimOrNull(input.HouseNumber); + entity.Phone = TrimOrNull(input.Phone); + entity.TelePhone = TrimOrNull(input.TelePhone); + entity.Email = TrimOrNull(input.Email); + entity.IsDefault = input.IsDefault ? (byte)1 : (byte)0; + + await CustomerSenderAddressRepository.UpdateAsync(entity); + } + + /// + /// 删除发件地址(软删)。 + /// + [HttpPost("/order/sender-address/delete")] + public async Task DeleteSenderAddressAsync([FromBody] IdInput input) + { + var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id); + await CustomerSenderAddressRepository.DeleteAsync(entity); + } + + /// + /// 设为默认发件地址。 + /// + [HttpPost("/order/sender-address/set-default")] + public async Task SetDefaultSenderAddressAsync([FromBody] IdInput input) + { + var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id); + await CustomerSenderAddressRepository.ClearDefaultAsync(entity.CustomerId, entity.Id); + entity.IsDefault = 1; + await CustomerSenderAddressRepository.UpdateAsync(entity); + } + + private async Task GetOwnedSenderAddressOrThrowAsync(int id) + { + var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(CustomerRepository, CurrentUser); + var entity = await CustomerSenderAddressRepository.GetOwnedAsync(id, customer.Id); + if (entity == null) + { + throw new BusinessException("发件地址不存在"); + } + + return entity; + } + + private static void ValidateSenderAddressInput(CreateCustomerSenderAddressInput input) + { + if (string.IsNullOrWhiteSpace(input.SenderName)) + { + throw new BusinessException("发件人姓名不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.StreetAddress1)) + { + throw new BusinessException("发件地址不能为空"); + } + } + + private async Task ResolveSenderCountryIdAsync(string? countryCode) + { + var code = string.IsNullOrWhiteSpace(countryCode) ? "CN" : countryCode.Trim(); + var country = await GetCountryOrThrowAsync(code, "发件国家不存在"); + return country.Id; + } + + private static Expression> BuildSenderAddressPredicate( + int customerId, + CustomerSenderAddressPaginatedInput input) + { + var keyword = input.Keyword?.Trim(); + var onlyDefault = input.OnlyDefault; + return x => + x.CustomerId == customerId + && (!onlyDefault || x.IsDefault == 1) + && (string.IsNullOrWhiteSpace(keyword) + || (x.AddressName ?? string.Empty).Contains(keyword) + || x.SenderName.Contains(keyword) + || (x.Phone ?? string.Empty).Contains(keyword) + || x.StreetAddress1.Contains(keyword)); + } + + private async Task> LoadCountryCodeMapAsync(IEnumerable countryIds) + { + var ids = countryIds.Distinct().ToArray(); + if (ids.Length == 0) + { + return new Dictionary(); + } + + var countries = await CountryRepository.GetListAsync(x => ids.Contains(x.Id)); + return countries.ToDictionary(x => x.Id, x => x.CountryCode ?? string.Empty); + } + + private static CustomerSenderAddressOutput MapSenderAddressOutput( + CustomerSenderAddress entity, + IReadOnlyDictionary countryMap) + { + string? countryCode = null; + if (entity.Country.HasValue && countryMap.TryGetValue(entity.Country.Value, out var code)) + { + countryCode = code; + } + + return new CustomerSenderAddressOutput + { + Id = entity.Id, + CustomerId = entity.CustomerId, + AddressName = entity.AddressName, + SenderName = entity.SenderName, + Company = entity.Company, + Country = entity.Country, + CountryCode = countryCode, + Province = entity.Province, + City = entity.City, + ZipCode = entity.ZipCode, + StreetAddress1 = entity.StreetAddress1, + StreetAddress2 = entity.StreetAddress2, + StreetAddress3 = entity.StreetAddress3, + HouseNumber = entity.HouseNumber, + Phone = entity.Phone, + TelePhone = entity.TelePhone, + Email = entity.Email, + IsDefault = entity.IsDefault > 0, + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; + } + + private static string? TrimOrNull(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index d868605..19345ad 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -454,6 +454,7 @@ public partial class OrderService : ApplicationService { Status = input.Status, HandleStatus = input.HandleStatus, + OrderSource = input.OrderSource, Keyword = input.Keyword, CreateTimeStart = input.CreateTimeStart, CreateTimeEnd = input.CreateTimeEnd, @@ -464,6 +465,7 @@ public partial class OrderService : ApplicationService new() { Status = input.Status, + OrderSource = input.OrderSource, Keyword = input.Keyword, CreateTimeStart = input.CreateTimeStart, CreateTimeEnd = input.CreateTimeEnd, diff --git a/src/Oms.Application/Services/Orders/PickupOrderParcelBuilder.cs b/src/Oms.Application/Services/Orders/PickupOrderParcelBuilder.cs new file mode 100644 index 0000000..27b6de5 --- /dev/null +++ b/src/Oms.Application/Services/Orders/PickupOrderParcelBuilder.cs @@ -0,0 +1,89 @@ +using Oms.Application.Dtos.Orders; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +/// +/// 揽收订单 → 小包创建入参组装。 +/// +public static class PickupOrderParcelBuilder +{ + public static OrderParcelCreateInput Build( + OrderPickupCreateInput input, + CustomerSenderAddress senderAddress, + WarehouseAddress warehouse, + int customerId, + string? senderCountryCode) + { + var cargoName = input.CargoName.Trim(); + var weight = input.ActualWeight; + var clientNo = string.IsNullOrWhiteSpace(input.ClientNo) + ? $"PK{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}" + : input.ClientNo.Trim(); + + var receiverName = string.IsNullOrWhiteSpace(warehouse.Recipient) + ? warehouse.WarehouseName + : warehouse.Recipient!.Trim(); + var street = string.IsNullOrWhiteSpace(warehouse.ShippingAddress) + ? warehouse.WarehouseName + : warehouse.ShippingAddress!.Trim(); + + return new OrderParcelCreateInput + { + RequestId = string.IsNullOrWhiteSpace(input.RequestId) ? null : input.RequestId.Trim(), + ClientNo = clientNo, + ProductCode = input.ProductCode.Trim(), + WarehouseId = input.WarehouseId, + CustomerId = customerId, + Length = 1, + Width = 1, + Height = 1, + ActualWeight = weight, + CargoAttribute = string.IsNullOrWhiteSpace(input.CargoAttribute) + ? CargoAttributeCodes.General + : input.CargoAttribute.Trim(), + DeclaredCurrency = "CNY", + DeclaredSum = 1, + Quantity = 1, + Sender = new OrderParcelSenderInput + { + SenderName = senderAddress.SenderName, + Company = senderAddress.Company, + Phone = senderAddress.Phone, + TelePhone = senderAddress.TelePhone, + Email = senderAddress.Email, + CountryCode = senderCountryCode ?? "CN", + Province = senderAddress.Province, + City = senderAddress.City, + ZipCode = senderAddress.ZipCode, + StreetAddress1 = senderAddress.StreetAddress1, + StreetAddress2 = senderAddress.StreetAddress2, + StreetAddress3 = senderAddress.StreetAddress3, + HouseNumber = senderAddress.HouseNumber + }, + Receiver = new OrderParcelReceiverInput + { + RecipientsName = receiverName, + Phone = warehouse.ContactPhone, + CountryCode = "CN", + ZipCode = "000000", + StreetAddress1 = street + }, + Items = + [ + new OrderParcelItemInput + { + CnName = cargoName, + EnName = cargoName, + UnitPrice = 1, + UnitWeight = weight, + Quantity = 1, + Amount = 1, + Weight = weight + } + ] + }; + } +} diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs index 47edf02..7549fde 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using Fake.Application; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Warehouse; @@ -184,6 +185,18 @@ public partial class WarehouseService return MapParcelOutput(parcel, savedItems); } + /// + /// 上传包裹商品图片,返回可访问相对路径。 + /// + [HttpPost("/warehouse/parcel/upload-image")] + [Consumes("multipart/form-data")] + public async Task UploadParcelImageAsync(IFormFile file) + { + await ResolveMutationCustomerIdAsync(null); + var path = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, WebHostEnvironment); + return new { url = path, fileUrl = path }; + } + /// /// 删除包裹。 /// diff --git a/src/Oms.Application/Shared/OmsFileUploadHelper.cs b/src/Oms.Application/Shared/OmsFileUploadHelper.cs index bc9b366..2116e2e 100644 --- a/src/Oms.Application/Shared/OmsFileUploadHelper.cs +++ b/src/Oms.Application/Shared/OmsFileUploadHelper.cs @@ -26,6 +26,12 @@ public static class OmsFileUploadHelper public static Task SaveWarehouseSkuImageAsync(IFormFile file, IWebHostEnvironment env) => SaveImageAsync(file, env, "sku", "SKU 图片"); + /// + /// 保存包裹商品图片。 + /// + public static Task SaveWarehouseParcelImageAsync(IFormFile file, IWebHostEnvironment env) => + SaveImageAsync(file, env, "parcel", "包裹商品图片"); + private static async Task SaveImageAsync( IFormFile? file, IWebHostEnvironment env, diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderSourceCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderSourceCodes.cs new file mode 100644 index 0000000..6ee469b --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderSourceCodes.cs @@ -0,0 +1,22 @@ +namespace Oms.Domain.OrdersAggregate.Consts; + +/// +/// 订单来源编码。 +/// +public static class OrderSourceCodes +{ + public const string Manual = "manual"; + public const string Pickup = "pickup"; + public const string Upload = "upload"; + public const string Batch = "batch"; + public const string Platform = "platform"; + + public static readonly HashSet All = new(StringComparer.OrdinalIgnoreCase) + { + Manual, + Pickup, + Upload, + Batch, + Platform + }; +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/CustomerSenderAddress.cs b/src/Oms.Domain/OrdersAggregate/Entities/CustomerSenderAddress.cs new file mode 100644 index 0000000..2fe0141 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/CustomerSenderAddress.cs @@ -0,0 +1,43 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.OrdersAggregate.Entities; + +/// +/// 客户发件地址簿。 +/// +[SugarTable("t_customer_sender_address")] +public class CustomerSenderAddress : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public string? AddressName { get; set; } + + public string SenderName { get; set; } = string.Empty; + + public string? Company { get; set; } + + public int? Country { get; set; } + + public string? Province { get; set; } + + public string? City { get; set; } + + public string? ZipCode { get; set; } + + public string StreetAddress1 { get; set; } = string.Empty; + + public string? StreetAddress2 { get; set; } + + public string? StreetAddress3 { get; set; } + + public string? HouseNumber { get; set; } + + public string? Phone { get; set; } + + public string? TelePhone { get; set; } + + public string? Email { get; set; } + + public byte IsDefault { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index 2933a77..80e6fff 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -87,6 +87,21 @@ public class Order : OmsAuditedAggregateRoot public int? CustomerId { get; set; } + /// + /// 订单来源:manual / pickup / upload / batch / platform。 + /// + public string? OrderSource { get; set; } + + /// + /// 预约揽收时间(OrderSource=pickup)。 + /// + public DateTime? PickupTime { get; set; } + + /// + /// 创建时选用的发件地址簿 Id。 + /// + public int? CustomerSenderAddressId { get; set; } + public string? OrganizationId { get; set; } public int SecretLevel { get; set; } = 2; diff --git a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs index b3bf3af..f7e7a34 100644 --- a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs +++ b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs @@ -9,6 +9,8 @@ public class OrderListFilter public string? HandleStatus { get; set; } + public string? OrderSource { get; set; } + public string? Keyword { get; set; } public DateTime? CreateTimeStart { get; set; } diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/ICustomerSenderAddressRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/ICustomerSenderAddressRepository.cs new file mode 100644 index 0000000..c8dce6b --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Repositories/ICustomerSenderAddressRepository.cs @@ -0,0 +1,11 @@ +using Fake.Domain.Repositories; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Domain.OrdersAggregate.Repositories; + +public interface ICustomerSenderAddressRepository : IRepository +{ + Task GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default); + + Task ClearDefaultAsync(int customerId, int? excludeId = null, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.SqlSugarCore/Orders/CustomerSenderAddressRepository.cs b/src/Oms.SqlSugarCore/Orders/CustomerSenderAddressRepository.cs new file mode 100644 index 0000000..91bba1d --- /dev/null +++ b/src/Oms.SqlSugarCore/Orders/CustomerSenderAddressRepository.cs @@ -0,0 +1,32 @@ +using Fake.DependencyInjection; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Orders; + +public class CustomerSenderAddressRepository : OmsSqlSugarRepository, ICustomerSenderAddressRepository, IScopedDependency +{ + public Task GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken); + } + + public async Task ClearDefaultAsync(int customerId, int? excludeId = null, CancellationToken cancellationToken = default) + { + var list = await GetListAsync( + x => x.CustomerId == customerId && x.IsDefault == 1 + && (!excludeId.HasValue || x.Id != excludeId.Value), + cancellationToken: cancellationToken); + if (list.Count == 0) + { + return; + } + + foreach (var item in list) + { + item.IsDefault = 0; + await UpdateAsync(item); + } + } +} diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index 47755f6..f18e682 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -90,6 +90,13 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I } } + var orderSource = filter.OrderSource?.Trim(); + if (!string.IsNullOrWhiteSpace(orderSource) + && !string.Equals(orderSource, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(x => x.OrderSource == orderSource); + } + query = OrderListScopeFilterApplier.Apply(query, filter); return query;