新增快递到仓 API 与表结构,并支持 SKU 图片上传。

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-14 20:06:47 +08:00
parent 600ab37594
commit d0df7e864c
10 changed files with 311 additions and 7 deletions

18
db.sql
View File

@ -40,6 +40,7 @@ DROP TABLE IF EXISTS `C_inbound_box_line`;
DROP TABLE IF EXISTS `C_inbound_box`;
DROP TABLE IF EXISTS `C_inbound_notice`;
DROP TABLE IF EXISTS `C_parcel_item`;
DROP TABLE IF EXISTS `C_parcel_arrival`;
DROP TABLE IF EXISTS `C_parcel`;
DROP TABLE IF EXISTS `C_inventory`;
DROP TABLE IF EXISTS `C_sku_platform_map`;
@ -1357,6 +1358,23 @@ CREATE TABLE `C_parcel` (
KEY `idx_c_parcel_status` (`CustomerId`, `LifecycleStatus`)
) COMMENT='仓储-客户包裹';
CREATE TABLE `C_parcel_arrival` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`CustomerId` INT NOT NULL COMMENT '客户ID',
`WarehouseId` INT NOT NULL COMMENT '仓库ID',
`ParcelId` INT NULL COMMENT '关联包裹 C_parcel.Id',
`TrackingNo` VARCHAR(64) NOT 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_c_parcel_arrival_tracking` (`CustomerId`, `TrackingNo`, `WarehouseId`),
KEY `idx_c_parcel_arrival_ctime` (`CreateTime`)
) COMMENT='仓储-快递到仓记录';
CREATE TABLE `C_parcel_item` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`ParcelId` INT NOT NULL COMMENT '包裹ID → C_parcel.Id',

20
sql/C_parcel_arrival.sql Normal file
View File

@ -0,0 +1,20 @@
-- 已有库增量:快递到仓表 C_parcel_arrival
-- 对应功能:操作 → 快递到仓;全量结构见根目录 db.sql
-- 可重复执行(IF NOT EXISTS)
CREATE TABLE IF NOT EXISTS `C_parcel_arrival` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`CustomerId` INT NOT NULL COMMENT '客户ID',
`WarehouseId` INT NOT NULL COMMENT '仓库ID',
`ParcelId` INT NULL COMMENT '关联包裹 C_parcel.Id',
`TrackingNo` VARCHAR(64) NOT 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_c_parcel_arrival_tracking` (`CustomerId`, `TrackingNo`, `WarehouseId`),
KEY `idx_c_parcel_arrival_ctime` (`CreateTime`)
) COMMENT='仓储-快递到仓记录';

View File

@ -3,5 +3,5 @@
OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。
- 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行)
- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER,不再维护 `migrate_*.sql`
- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录下单个表脚本(如 `C_parcel_arrival.sql`)可直接在已有库执行增量建表
- 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落

View File

@ -466,3 +466,44 @@ public class WarehouseInventoryRelocateInput
[Range(1, int.MaxValue)]
public int Qty { get; set; }
}
// --- Parcel arrival(快递到仓)---
public class WarehouseParcelArrivalListInput : WarehousePageInput
{
public int? WarehouseId { get; set; }
public string? Keyword { get; set; }
}
public class WarehouseParcelArrivalCreateInput
{
public int? CustomerId { get; set; }
[Required]
public int WarehouseId { get; set; }
[Required]
public string TrackingNo { get; set; } = string.Empty;
}
public class WarehouseParcelArrivalOutput
{
public int Id { get; set; }
public int CustomerId { get; set; }
public int WarehouseId { get; set; }
public string? WarehouseName { get; set; }
public int? ParcelId { get; set; }
public string TrackingNo { get; set; } = string.Empty;
public DateTime CreateTime { get; set; }
public int CreateUserId { get; set; }
public string? CreateBy { get; set; }
}

View File

@ -0,0 +1,165 @@
using System.Linq.Expressions;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
using Oms.Application.Shared;
using Oms.Domain.SystemAggregate.Repositories;
using Oms.Domain.WarehouseAggregate.Consts;
using Oms.Domain.WarehouseAggregate.Entities;
using Oms.Domain.WarehouseAggregate.Repositories;
namespace Oms.Application.Services.Warehouse;
public partial class WarehouseService
{
private ICParcelArrivalRepository ParcelArrivalRepository =>
LazyServiceProvider.GetRequiredService<ICParcelArrivalRepository>();
private IWarehouseAddressRepository WarehouseAddressRepository =>
LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
/// <summary>
/// 快递到仓记录列表。
/// </summary>
[HttpPost("/warehouse/parcel-arrival/list")]
public async Task<OmsPageResult<WarehouseParcelArrivalOutput>> GetParcelArrivalListAsync(
WarehouseParcelArrivalListInput input)
{
var customerId = await ResolveListCustomerIdAsync(input.CustomerId);
var (hasCustomerFilter, customerIdValue) = CustomerFilter(customerId);
var keyword = input.Keyword?.Trim();
var hasKeyword = !string.IsNullOrWhiteSpace(keyword);
var hasWarehouseFilter = input.WarehouseId is > 0;
var warehouseIdValue = input.WarehouseId.GetValueOrDefault();
Expression<Func<CParcelArrival, bool>> predicate = x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& (!hasWarehouseFilter || x.WarehouseId == warehouseIdValue)
&& (!hasKeyword || x.TrackingNo.Contains(keyword!));
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await ParcelArrivalRepository.GetPagedListAsync(
predicate,
pageIndex,
pageSize,
new Dictionary<string, bool>
{
[nameof(CParcelArrival.CreateTime)] = false,
[nameof(CParcelArrival.Id)] = false
});
var total = await ParcelArrivalRepository.CountAsync(predicate);
var warehouseIds = entities.Select(x => x.WarehouseId).Distinct().ToArray();
var warehouses = warehouseIds.Length == 0
? []
: await WarehouseAddressRepository.GetListAsync(x => warehouseIds.Contains(x.Id));
var warehouseNameMap = warehouses.ToDictionary(x => x.Id, x => x.WarehouseName);
var userIds = entities.Select(x => x.CreateUserId).Distinct().ToArray();
var userNameMap = await UserDisplayNameResolver.ResolveManyAsync(userIds);
return new OmsPageResult<WarehouseParcelArrivalOutput>
{
Items = entities.Select(x => new WarehouseParcelArrivalOutput
{
Id = x.Id,
CustomerId = x.CustomerId,
WarehouseId = x.WarehouseId,
WarehouseName = warehouseNameMap.GetValueOrDefault(x.WarehouseId),
ParcelId = x.ParcelId,
TrackingNo = x.TrackingNo,
CreateTime = x.CreateTime,
CreateUserId = x.CreateUserId,
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(x.CreateUserId, userNameMap)
}).ToList(),
Total = total
};
}
/// <summary>
/// 快递到仓:写入到仓记录,并将对应「我的包裹」状态改为 arrived。
/// </summary>
[HttpPost("/warehouse/parcel-arrival/create")]
public async Task<WarehouseParcelArrivalOutput> CreateParcelArrivalAsync(WarehouseParcelArrivalCreateInput input)
{
var customerId = await ResolveMutationCustomerIdAsync(input.CustomerId);
if (input.WarehouseId <= 0)
{
throw new BusinessException("仓库ID不能为空");
}
var trackingNo = input.TrackingNo.Trim();
if (string.IsNullOrWhiteSpace(trackingNo))
{
throw new BusinessException("国内快递单号不能为空");
}
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId)
?? throw new BusinessException("仓库不存在");
var parcel = await ParcelRepository.FirstOrDefaultAsync(x =>
x.CustomerId == customerId
&& x.WarehouseId == input.WarehouseId
&& x.TrackingNo == trackingNo);
if (parcel == null)
{
throw new BusinessException("未找到对应包裹,请确认快递单号与仓库");
}
if (parcel.LifecycleStatus == WarehouseParcelLifecycleCodes.Stored)
{
throw new BusinessException("包裹已上架,无法再到仓");
}
parcel.LifecycleStatus = WarehouseParcelLifecycleCodes.Arrived;
parcel.ArrivalTime ??= DateTime.Now;
await ParcelRepository.UpdateAsync(parcel);
var entity = new CParcelArrival
{
CustomerId = customerId,
WarehouseId = input.WarehouseId,
ParcelId = parcel.Id,
TrackingNo = trackingNo
};
await ParcelArrivalRepository.InsertAsync(entity);
var userNameMap = await UserDisplayNameResolver.ResolveManyAsync([entity.CreateUserId]);
return new WarehouseParcelArrivalOutput
{
Id = entity.Id,
CustomerId = entity.CustomerId,
WarehouseId = entity.WarehouseId,
WarehouseName = warehouse.WarehouseName,
ParcelId = entity.ParcelId,
TrackingNo = entity.TrackingNo,
CreateTime = entity.CreateTime,
CreateUserId = entity.CreateUserId,
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.CreateUserId, userNameMap)
};
}
/// <summary>
/// 删除快递到仓记录(不回退包裹状态)。
/// </summary>
[HttpPost("/warehouse/parcel-arrival/delete")]
public async Task DeleteParcelArrivalAsync(IdInput input)
{
var customerId = await ResolveListCustomerIdAsync(null);
var entity = customerId.HasValue
? await ParcelArrivalRepository.FirstOrDefaultAsync(x =>
x.Id == input.Id && x.CustomerId == customerId.Value)
: await ParcelArrivalRepository.FirstOrDefaultAsync(x => x.Id == input.Id);
if (entity == null)
{
throw new BusinessException("到仓记录不存在");
}
await ParcelArrivalRepository.DeleteAsync(entity);
}
}

View File

@ -1,5 +1,7 @@
using System.Linq.Expressions;
using Fake.Application;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
@ -9,6 +11,9 @@ namespace Oms.Application.Services.Warehouse;
public partial class WarehouseService
{
private IWebHostEnvironment WebHostEnvironment =>
LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
/// <summary>
/// 查询 SKU 列表。
/// </summary>
@ -142,6 +147,18 @@ public partial class WarehouseService
await SkuRepository.DeleteAsync(entity);
}
/// <summary>
/// 上传 SKU 图片,返回可访问相对路径。
/// </summary>
[HttpPost("/warehouse/sku/upload-image")]
[Consumes("multipart/form-data")]
public async Task<object> UploadSkuImageAsync(IFormFile file)
{
await ResolveMutationCustomerIdAsync(null);
var path = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, WebHostEnvironment);
return new { url = path, fileUrl = path };
}
/// <summary>
/// 绑定平台 SKU 与库内 SKU 映射。
/// </summary>

View File

@ -17,16 +17,34 @@ public static class OmsFileUploadHelper
/// <summary>
/// 保存充值截图,返回以 /uploads 开头的相对访问路径。
/// </summary>
public static async Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IWebHostEnvironment env)
public static Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IWebHostEnvironment env) =>
SaveImageAsync(file, env, "recharge", "充值截图");
/// <summary>
/// 保存仓储 SKU 图片。
/// </summary>
public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IWebHostEnvironment env) =>
SaveImageAsync(file, env, "sku", "SKU 图片");
private static async Task<string> SaveImageAsync(
IFormFile? file,
IWebHostEnvironment env,
string folder,
string label)
{
if (file == null || file.Length <= 0)
{
return "0";
if (folder == "recharge")
{
return "0";
}
throw new BusinessException($"{label}不能为空");
}
if (file.Length > MaxImageBytes)
{
throw new BusinessException("充值截图大小不能超过 5MB");
throw new BusinessException($"{label}大小不能超过 5MB");
}
var ext = Path.GetExtension(file.FileName);
@ -37,7 +55,7 @@ public static class OmsFileUploadHelper
if (!ImageExtensions.Contains(ext))
{
throw new BusinessException("充值截图仅支持 jpg、png、webp、gif、bmp 格式");
throw new BusinessException($"{label}仅支持 jpg、png、webp、gif、bmp 格式");
}
var webRoot = env.WebRootPath;
@ -47,7 +65,7 @@ public static class OmsFileUploadHelper
}
var monthFolder = DateTime.Now.ToString("yyyyMM");
var relativeDir = Path.Combine("uploads", "recharge", monthFolder);
var relativeDir = Path.Combine("uploads", folder, monthFolder);
var absoluteDir = Path.Combine(webRoot, relativeDir);
Directory.CreateDirectory(absoluteDir);
@ -58,7 +76,7 @@ public static class OmsFileUploadHelper
await file.CopyToAsync(stream);
}
return $"/uploads/recharge/{monthFolder}/{fileName}".Replace('\\', '/');
return $"/uploads/{folder}/{monthFolder}/{fileName}".Replace('\\', '/');
}
private static readonly HashSet<string> ExcelExtensions =

View File

@ -0,0 +1,21 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.WarehouseAggregate.Entities;
/// <summary>
/// 快递到仓扫描记录。
/// </summary>
[SugarTable("C_parcel_arrival")]
public class CParcelArrival : OmsAuditedAggregateRoot
{
public int CustomerId { get; set; }
public int WarehouseId { get; set; }
/// <summary>关联我的包裹(可为空,兼容历史)。</summary>
public int? ParcelId { get; set; }
/// <summary>国内快递单号。</summary>
public string TrackingNo { get; set; } = string.Empty;
}

View File

@ -19,4 +19,6 @@ public interface ICParcelRepository : IRepository<CParcel>;
public interface ICParcelItemRepository : IRepository<CParcelItem>;
public interface ICParcelArrivalRepository : IRepository<CParcelArrival>;
public interface ICSkuPlatformMapRepository : IRepository<CSkuPlatformMap>;

View File

@ -21,4 +21,6 @@ public class CParcelRepository : OmsSqlSugarRepository<CParcel>, ICParcelReposit
public class CParcelItemRepository : OmsSqlSugarRepository<CParcelItem>, ICParcelItemRepository, IScopedDependency;
public class CParcelArrivalRepository : OmsSqlSugarRepository<CParcelArrival>, ICParcelArrivalRepository, IScopedDependency;
public class CSkuPlatformMapRepository : OmsSqlSugarRepository<CSkuPlatformMap>, ICSkuPlatformMapRepository, IScopedDependency;