feat: 客户发件地址与揽收订单,复用小包创建全链路。

新增地址簿 CRUD、pickup/create、订单 orderSource 筛选;附带包裹商品图片上传接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-15 18:58:48 +08:00
parent 4b7c396d92
commit 341a318f0b
23 changed files with 942 additions and 4 deletions

35
db.sql
View File

@ -133,6 +133,9 @@ CREATE TABLE `t_order` (
`PreviousOrderStatus` VARCHAR(32) NULL COMMENT '进入异常状态前的订单状态', `PreviousOrderStatus` VARCHAR(32) NULL COMMENT '进入异常状态前的订单状态',
`WarehouseId` INT NULL COMMENT '仓库ID', `WarehouseId` INT NULL COMMENT '仓库ID',
`CustomerId` 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 '数据归属组织', `OrganizationId` CHAR(36) NULL COMMENT '数据归属组织',
`SecretLevel` TINYINT NOT NULL DEFAULT 2 COMMENT '数据密级 1-4', `SecretLevel` TINYINT NOT NULL DEFAULT 2 COMMENT '数据密级 1-4',
`SenderId` INT NULL COMMENT '发件人ID', `SenderId` INT NULL COMMENT '发件人ID',
@ -168,9 +171,39 @@ CREATE TABLE `t_order` (
KEY `idx_ord_sender` (`SenderId`), KEY `idx_ord_sender` (`SenderId`),
KEY `idx_ord_recipient` (`RecipientId`), KEY `idx_ord_recipient` (`RecipientId`),
KEY `idx_ord_declaration` (`DeclarationId`), 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='订单表'; ) 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` ( CREATE TABLE `t_order_template` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`Name` VARCHAR(64) NOT NULL COMMENT '模板名称', `Name` VARCHAR(64) NOT NULL COMMENT '模板名称',

View File

@ -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='客户发件地址簿';

View File

@ -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;

View File

@ -0,0 +1,159 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 客户发件地址分页查询。
/// </summary>
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; }
}
/// <summary>
/// 新增发件地址。
/// </summary>
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; }
}
/// <summary>
/// 更新发件地址。
/// </summary>
public class UpdateCustomerSenderAddressInput : CreateCustomerSenderAddressInput
{
[Required]
public int Id { get; set; }
}
/// <summary>
/// 发件地址出参。
/// </summary>
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; }
}
/// <summary>
/// 创建揽收订单入参。
/// </summary>
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; }
}

View File

@ -11,6 +11,12 @@ public class OrderExportInput
[Newtonsoft.Json.JsonProperty("status")] [Newtonsoft.Json.JsonProperty("status")]
public string? Status { get; set; } public string? Status { get; set; }
/// <summary>
/// 订单来源:all / manual / pickup 等。
/// </summary>
[Newtonsoft.Json.JsonProperty("orderSource")]
public string? OrderSource { get; set; }
/// <summary> /// <summary>
/// 关键词。 /// 关键词。
/// </summary> /// </summary>

View File

@ -95,6 +95,21 @@ public class OrderOutput
/// </summary> /// </summary>
public int? CustomerId { get; set; } public int? CustomerId { get; set; }
/// <summary>
/// 订单来源。
/// </summary>
public string? OrderSource { get; set; }
/// <summary>
/// 预约揽收时间。
/// </summary>
public DateTime? PickupTime { get; set; }
/// <summary>
/// 发件地址簿 Id。
/// </summary>
public int? CustomerSenderAddressId { get; set; }
/// <summary> /// <summary>
/// 幂等请求标识。 /// 幂等请求标识。
/// </summary> /// </summary>

View File

@ -31,6 +31,13 @@ public class OrderPaginatedInput
[global::System.Text.Json.Serialization.JsonPropertyName("handleStatus")] [global::System.Text.Json.Serialization.JsonPropertyName("handleStatus")]
public string? HandleStatus { get; set; } public string? HandleStatus { get; set; }
/// <summary>
/// 订单来源:all / manual / pickup / upload / batch / platform。
/// </summary>
[Newtonsoft.Json.JsonProperty("orderSource")]
[global::System.Text.Json.Serialization.JsonPropertyName("orderSource")]
public string? OrderSource { get; set; }
/// <summary> /// <summary>
/// 关键词。 /// 关键词。
/// </summary> /// </summary>

View File

@ -119,6 +119,11 @@ public partial class OrderService
? warehouse.WarehouseName ? warehouse.WarehouseName
: null, : null,
CustomerId = order.CustomerId, CustomerId = order.CustomerId,
OrderSource = string.IsNullOrWhiteSpace(order.OrderSource)
? OrderSourceCodes.Manual
: order.OrderSource,
PickupTime = order.PickupTime,
CustomerSenderAddressId = order.CustomerSenderAddressId,
RequestId = order.RequestId, RequestId = order.RequestId,
ExportCustomsMode = order.ExportCustomsMode, ExportCustomsMode = order.ExportCustomsMode,
PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason) PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason)

View File

@ -276,6 +276,8 @@ public partial class OrderService
return new OrderListFilter return new OrderListFilter
{ {
Status = filter.Status, Status = filter.Status,
HandleStatus = filter.HandleStatus,
OrderSource = filter.OrderSource,
Keyword = filter.Keyword, Keyword = filter.Keyword,
CreateTimeStart = start, CreateTimeStart = start,
CreateTimeEnd = filter.CreateTimeEnd, CreateTimeEnd = filter.CreateTimeEnd,

View File

@ -40,9 +40,19 @@ public partial class OrderService
private OrderAutoTriggerService OrderAutoTriggerService => private OrderAutoTriggerService OrderAutoTriggerService =>
LazyServiceProvider.GetRequiredService<OrderAutoTriggerService>(); LazyServiceProvider.GetRequiredService<OrderAutoTriggerService>();
private sealed class ParcelCreateContext
{
public string? OrderSource { get; init; }
public DateTime? PickupTime { get; init; }
public int? CustomerSenderAddressId { get; init; }
}
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync( private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(
OrderParcelCreateInput input, OrderParcelCreateInput input,
int? operatorUserId = null) int? operatorUserId = null,
ParcelCreateContext? createContext = null)
{ {
if (!string.IsNullOrWhiteSpace(input.RequestId)) if (!string.IsNullOrWhiteSpace(input.RequestId))
{ {
@ -88,7 +98,14 @@ public partial class OrderService
country.ChineseName); country.ChineseName);
var serviceNo = await ResolveServiceNoAsync(); 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); order = await OrderRepository.InsertAsync(order);
var weights = BuildInitialWeightRows(order.Id, input); var weights = BuildInitialWeightRows(order.Id, input);
@ -1076,8 +1093,13 @@ public partial class OrderService
string consigneeCountry, string consigneeCountry,
string consigneeCountryName, string consigneeCountryName,
ProductSnapshotBinding snapshotBinding, ProductSnapshotBinding snapshotBinding,
string serviceNo) string serviceNo,
ParcelCreateContext? createContext = null)
{ {
var orderSource = string.IsNullOrWhiteSpace(createContext?.OrderSource)
? OrderSourceCodes.Manual
: createContext!.OrderSource!.Trim();
return new Order return new Order
{ {
RequestId = input.RequestId, RequestId = input.RequestId,
@ -1092,6 +1114,9 @@ public partial class OrderService
ExportCustomsMode = input.ExportCustomsMode, ExportCustomsMode = input.ExportCustomsMode,
WarehouseId = input.WarehouseId, WarehouseId = input.WarehouseId,
CustomerId = input.CustomerId, CustomerId = input.CustomerId,
OrderSource = orderSource,
PickupTime = createContext?.PickupTime,
CustomerSenderAddressId = createContext?.CustomerSenderAddressId,
IsCod = input.IsCod ? (byte)1 : (byte)0, IsCod = input.IsCod ? (byte)1 : (byte)0,
CodCurrency = input.CodCurrency, CodCurrency = input.CodCurrency,
CodSum = input.CodSum, CodSum = input.CodSum,

View File

@ -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
{
/// <summary>
/// 创建揽收订单(组装小包入参后复用创建核心)。
/// </summary>
[HttpPost("/order/pickup/create")]
public async Task<OrderParcelCreateOutput> 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
});
}
}

View File

@ -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<ICustomerSenderAddressRepository>();
/// <summary>
/// 发件地址分页列表。
/// </summary>
[HttpPost("/order/sender-address/list")]
public async Task<OmsPageResult<CustomerSenderAddressOutput>> 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<string, bool>
{
[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<CustomerSenderAddressOutput>
{
Items = entities.Select(x => MapSenderAddressOutput(x, countryMap)).ToList(),
Total = total
};
}
/// <summary>
/// 发件地址详情。
/// </summary>
[HttpGet("/order/sender-address/detail")]
public async Task<CustomerSenderAddressOutput> GetSenderAddressDetailAsync([FromQuery] int id)
{
var entity = await GetOwnedSenderAddressOrThrowAsync(id);
var countryMap = await LoadCountryCodeMapAsync(entity.Country.HasValue ? [entity.Country.Value] : []);
return MapSenderAddressOutput(entity, countryMap);
}
/// <summary>
/// 新增发件地址。
/// </summary>
[HttpPost("/order/sender-address/create")]
public async Task<object> 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 };
}
/// <summary>
/// 更新发件地址。
/// </summary>
[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);
}
/// <summary>
/// 删除发件地址(软删)。
/// </summary>
[HttpPost("/order/sender-address/delete")]
public async Task DeleteSenderAddressAsync([FromBody] IdInput input)
{
var entity = await GetOwnedSenderAddressOrThrowAsync(input.Id);
await CustomerSenderAddressRepository.DeleteAsync(entity);
}
/// <summary>
/// 设为默认发件地址。
/// </summary>
[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<CustomerSenderAddress> 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<int?> ResolveSenderCountryIdAsync(string? countryCode)
{
var code = string.IsNullOrWhiteSpace(countryCode) ? "CN" : countryCode.Trim();
var country = await GetCountryOrThrowAsync(code, "发件国家不存在");
return country.Id;
}
private static Expression<Func<CustomerSenderAddress, bool>> 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<Dictionary<int, string>> LoadCountryCodeMapAsync(IEnumerable<int> countryIds)
{
var ids = countryIds.Distinct().ToArray();
if (ids.Length == 0)
{
return new Dictionary<int, string>();
}
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<int, string> 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();
}
}

View File

@ -454,6 +454,7 @@ public partial class OrderService : ApplicationService
{ {
Status = input.Status, Status = input.Status,
HandleStatus = input.HandleStatus, HandleStatus = input.HandleStatus,
OrderSource = input.OrderSource,
Keyword = input.Keyword, Keyword = input.Keyword,
CreateTimeStart = input.CreateTimeStart, CreateTimeStart = input.CreateTimeStart,
CreateTimeEnd = input.CreateTimeEnd, CreateTimeEnd = input.CreateTimeEnd,
@ -464,6 +465,7 @@ public partial class OrderService : ApplicationService
new() new()
{ {
Status = input.Status, Status = input.Status,
OrderSource = input.OrderSource,
Keyword = input.Keyword, Keyword = input.Keyword,
CreateTimeStart = input.CreateTimeStart, CreateTimeStart = input.CreateTimeStart,
CreateTimeEnd = input.CreateTimeEnd, CreateTimeEnd = input.CreateTimeEnd,

View File

@ -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;
/// <summary>
/// 揽收订单 → 小包创建入参组装。
/// </summary>
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
}
]
};
}
}

View File

@ -1,5 +1,6 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using Fake.Application; using Fake.Application;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse; using Oms.Application.Dtos.Warehouse;
@ -184,6 +185,18 @@ public partial class WarehouseService
return MapParcelOutput(parcel, savedItems); return MapParcelOutput(parcel, savedItems);
} }
/// <summary>
/// 上传包裹商品图片,返回可访问相对路径。
/// </summary>
[HttpPost("/warehouse/parcel/upload-image")]
[Consumes("multipart/form-data")]
public async Task<object> UploadParcelImageAsync(IFormFile file)
{
await ResolveMutationCustomerIdAsync(null);
var path = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, WebHostEnvironment);
return new { url = path, fileUrl = path };
}
/// <summary> /// <summary>
/// 删除包裹。 /// 删除包裹。
/// </summary> /// </summary>

View File

@ -26,6 +26,12 @@ public static class OmsFileUploadHelper
public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IWebHostEnvironment env) => public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IWebHostEnvironment env) =>
SaveImageAsync(file, env, "sku", "SKU 图片"); SaveImageAsync(file, env, "sku", "SKU 图片");
/// <summary>
/// 保存包裹商品图片。
/// </summary>
public static Task<string> SaveWarehouseParcelImageAsync(IFormFile file, IWebHostEnvironment env) =>
SaveImageAsync(file, env, "parcel", "包裹商品图片");
private static async Task<string> SaveImageAsync( private static async Task<string> SaveImageAsync(
IFormFile? file, IFormFile? file,
IWebHostEnvironment env, IWebHostEnvironment env,

View File

@ -0,0 +1,22 @@
namespace Oms.Domain.OrdersAggregate.Consts;
/// <summary>
/// 订单来源编码。
/// </summary>
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<string> All = new(StringComparer.OrdinalIgnoreCase)
{
Manual,
Pickup,
Upload,
Batch,
Platform
};
}

View File

@ -0,0 +1,43 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.OrdersAggregate.Entities;
/// <summary>
/// 客户发件地址簿。
/// </summary>
[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; }
}

View File

@ -87,6 +87,21 @@ public class Order : OmsAuditedAggregateRoot
public int? CustomerId { get; set; } public int? CustomerId { get; set; }
/// <summary>
/// 订单来源:manual / pickup / upload / batch / platform。
/// </summary>
public string? OrderSource { get; set; }
/// <summary>
/// 预约揽收时间(OrderSource=pickup)。
/// </summary>
public DateTime? PickupTime { get; set; }
/// <summary>
/// 创建时选用的发件地址簿 Id。
/// </summary>
public int? CustomerSenderAddressId { get; set; }
public string? OrganizationId { get; set; } public string? OrganizationId { get; set; }
public int SecretLevel { get; set; } = 2; public int SecretLevel { get; set; } = 2;

View File

@ -9,6 +9,8 @@ public class OrderListFilter
public string? HandleStatus { get; set; } public string? HandleStatus { get; set; }
public string? OrderSource { get; set; }
public string? Keyword { get; set; } public string? Keyword { get; set; }
public DateTime? CreateTimeStart { get; set; } public DateTime? CreateTimeStart { get; set; }

View File

@ -0,0 +1,11 @@
using Fake.Domain.Repositories;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Domain.OrdersAggregate.Repositories;
public interface ICustomerSenderAddressRepository : IRepository<CustomerSenderAddress>
{
Task<CustomerSenderAddress?> GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default);
Task ClearDefaultAsync(int customerId, int? excludeId = null, CancellationToken cancellationToken = default);
}

View File

@ -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<CustomerSenderAddress>, ICustomerSenderAddressRepository, IScopedDependency
{
public Task<CustomerSenderAddress?> 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);
}
}
}

View File

@ -90,6 +90,13 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, 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); query = OrderListScopeFilterApplier.Apply(query, filter);
return query; return query;