持久层重构为独立 MiGu.DB 工程并优化集成

- 新增 MiGu.DB 项目,迁移所有领域实体与枚举,统一模型约定
- 实现 Entity/Repository/UoW/Provider/Exception 等接口与实现
- 支持数据修补机制,完善 Sqlite 初始迁移与数据库管理
- Server 侧移除 EF Core 相关,依赖 MiGu.DB,PlatformPersistence 适配
- 业务服务注入 UoW/Repository,状态字段统一用 enum 及辅助类
- 统一异常处理,Controller 映射 HTTP 状态码
- 配置项与文档补充数据库启动、SchemaMode、迁移说明
- 新增 GlobalUsings.Db.cs、WmsStatusAliases.cs 简化类型引用
- 新增 HttpActorContextMiddleware 支持操作者上下文一致性
- 新增 MiGuDbContextModelSnapshot 追踪数据库结构
- 优化代码结构,解耦领域与持久层,提升扩展性与安全性
This commit is contained in:
2026-07-27 14:26:04 +08:00
parent bdcd88608c
commit 51c3fc1994
57 changed files with 6663 additions and 1396 deletions
+48 -342
View File
@@ -1,358 +1,64 @@
using System.ComponentModel.DataAnnotations;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
public abstract class WarehouseHistoryBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? RelationId { get; set; }
public string EventType { get; set; } = "";
public string BeforeJson { get; set; } = "{}";
public string AfterJson { get; set; } = "{}";
public Guid? ContainerId { get; set; }
public string Operator { get; set; } = "";
public DateTimeOffset OperatedAt { get; set; }
public string Source { get; set; } = "Manual";
public string Reason { get; set; } = "";
public string Remark { get; set; } = "";
public string Extend { get; set; } = "{}";
}
// API / 读模型 DTO(留在 Server,不进 MiGu.DB)。请求字段中的状态仍为 string,由 Service 解析为枚举后写入实体。
public sealed class Warehouse : EntityBase
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string Type { get; set; } = "Default";
public bool Enabled { get; set; } = true;
public int SortOrder { get; set; }
}
public sealed record InventoryMaterialRow(
Guid MaterialId, string MaterialCode, string MaterialName, string MaterialBarcode, string MaterialTypeCode,
Guid ContainerId, string ContainerCode, string ContainerName,
Guid? StorageId, string StorageCode, string StorageName,
Guid? AreaId, string AreaCode, string AreaName,
string LocationType, DateTimeOffset BoundAt);
public sealed class WarehouseArea : EntityBase
{
public Guid WarehouseId { get; set; }
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string Type { get; set; } = "Storage";
[MaxLength(32)] public string LayoutMode { get; set; } = AreaLayoutModes.Flat;
[MaxLength(32)] public string State { get; set; } = "Default";
public bool Enabled { get; set; } = true;
public int SortOrder { get; set; }
}
public sealed record ContainerLocationSnapshot(
Guid Id, Guid ContainerId, string LocationType, string LocationId, string LocationCode, string LocationName,
string Status, DateTimeOffset EnteredAt, long Version);
public sealed class Storage : EntityBase
{
public Guid AreaId { get; set; }
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string StorageType { get; set; } = "Storage";
[MaxLength(32)] public string LocationKind { get; set; } = LocationKinds.Station;
public int ColumnNo { get; set; }
public int LevelNo { get; set; } = 1;
public int DepthNo { get; set; } = 1;
[MaxLength(64)] public string SiteId { get; set; } = "";
[MaxLength(64)] public string SiteCode { get; set; } = "";
[MaxLength(128)] public string Barcode { get; set; } = "";
/// <summary>已废弃:一货位一容器,保留列兼容旧库。</summary>
public int Capacity { get; set; }
[MaxLength(32)] public string Status { get; set; } = StorageStatuses.Empty;
[MaxLength(64)] public string Usage { get; set; } = "";
public int Priority { get; set; }
[MaxLength(64)] public string ZoneCode { get; set; } = "";
public bool AllowInbound { get; set; } = true;
public bool AllowOutbound { get; set; } = true;
public bool Enabled { get; set; } = true;
}
public sealed record ContainerMaterialSnapshot(
Guid Id, Guid ContainerId, Guid MaterialId, decimal Quantity, string BatchNo, string SerialNo, string Status,
DateTimeOffset BoundAt, DateTimeOffset LoadedAt, DateTimeOffset? UnloadedAt, long Version);
public sealed class Container : EntityBase
{
public Guid? AreaId { get; set; }
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string ContainerType { get; set; } = "Box";
[MaxLength(64)] public string Status { get; set; } = ContainerStatuses.EmptyMaterial;
[MaxLength(128)] public string Barcode { get; set; } = "";
public double Length { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public bool Enabled { get; set; } = true;
}
public abstract record CommonRequest(Guid? Id, long? Version, bool IsLock, string Remark, string Extend);
public sealed class MaterialType : EntityBase
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(128)] public string Spec { get; set; } = "";
[MaxLength(32)] public string Unit { get; set; } = "pcs";
[MaxLength(64)] public string Category { get; set; } = "";
[MaxLength(64)] public string BarcodePrefix { get; set; } = "";
public bool Enabled { get; set; } = true;
}
public sealed record MasterDataRequest(
Guid? Id, long? Version, string Code, string Name, string Type, string Status, bool Enabled, int SortOrder,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed class Material : EntityBase
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string TypeCode { get; set; } = "";
[MaxLength(128)] public string Barcode { get; set; } = "";
[MaxLength(128)] public string Spec { get; set; } = "";
[MaxLength(32)] public string Unit { get; set; } = "pcs";
[MaxLength(64)] public string Category { get; set; } = "";
[MaxLength(32)] public string LifecycleStatus { get; set; } = MaterialLifecycles.Active;
public DateTimeOffset? UnboundAt { get; set; }
public bool Enabled { get; set; } = true;
}
public sealed record AreaRequest(
Guid? Id, long? Version, Guid? WarehouseId, string Code, string Name, string Type, string LayoutMode, string State,
bool Enabled, int SortOrder, bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed class ContainerLocation : EntityBase
{
public Guid ContainerId { get; set; }
[MaxLength(32)] public string LocationType { get; set; } = ContainerLocationTypes.Storage;
[MaxLength(64)] public string LocationId { get; set; } = "";
[MaxLength(64)] public string LocationCode { get; set; } = "";
[MaxLength(128)] public string LocationName { get; set; } = "";
[MaxLength(32)] public string Status { get; set; } = ContainerLocationStatuses.Active;
public DateTimeOffset EnteredAt { get; set; }
}
public sealed record StorageRequest(
Guid? Id, long? Version, Guid AreaId, string Code, string Name, string StorageType, string LocationKind,
int ColumnNo, int LevelNo, int DepthNo, string SiteId, string SiteCode, string Barcode, int Capacity,
string Status, string Usage, int Priority, string ZoneCode, bool AllowInbound, bool AllowOutbound, bool Enabled,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
/// <summary>容器-物料绑定(无数量语义;Quantity 列仅兼容旧库,固定为 1)。</summary>
public sealed class ContainerMaterial : EntityBase
{
public Guid ContainerId { get; set; }
public Guid MaterialId { get; set; }
public decimal Quantity { get; set; } = 1;
[MaxLength(64)] public string BatchNo { get; set; } = "";
[MaxLength(64)] public string SerialNo { get; set; } = "";
[MaxLength(32)] public string Status { get; set; } = ContainerMaterialStatuses.Bound;
public DateTimeOffset BoundAt { get; set; }
public DateTimeOffset LoadedAt { get; set; }
public DateTimeOffset? UnloadedAt { get; set; }
}
public sealed record GenerateBinsRequest(int ColumnFrom, int ColumnTo, int LevelFrom, int LevelTo, int DepthFrom, int DepthTo, string? CodePattern);
public sealed class StockEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(32)] public string EventType { get; set; } = "";
public Guid? MaterialId { get; set; }
[MaxLength(64)] public string MaterialCode { get; set; } = "";
[MaxLength(128)] public string MaterialName { get; set; } = "";
[MaxLength(128)] public string MaterialBarcode { get; set; } = "";
[MaxLength(64)] public string MaterialTypeCode { get; set; } = "";
public Guid? ContainerId { get; set; }
[MaxLength(64)] public string ContainerCode { get; set; } = "";
[MaxLength(128)] public string ContainerName { get; set; } = "";
public Guid? StorageId { get; set; }
[MaxLength(64)] public string StorageCode { get; set; } = "";
[MaxLength(128)] public string StorageName { get; set; } = "";
[MaxLength(64)] public string AreaCode { get; set; } = "";
public Guid? FromStorageId { get; set; }
[MaxLength(64)] public string FromStorageCode { get; set; } = "";
[MaxLength(128)] public string FromStorageName { get; set; } = "";
public Guid? ToStorageId { get; set; }
[MaxLength(64)] public string ToStorageCode { get; set; } = "";
[MaxLength(128)] public string ToStorageName { get; set; } = "";
[MaxLength(64)] public string RefType { get; set; } = "Manual";
public Guid? RefId { get; set; }
[MaxLength(64)] public string RefCode { get; set; } = "";
[MaxLength(128)] public string Operator { get; set; } = "";
public DateTimeOffset OperatedAt { get; set; }
[MaxLength(500)] public string Reason { get; set; } = "";
}
public sealed record ContainerRequest(
Guid? Id, long? Version, Guid? AreaId, string Code, string Name, string Type, string Status, string Barcode,
double Length, double Width, double Height, bool Enabled, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed class ContainerLocationHistory : WarehouseHistoryBase
{
[MaxLength(32)] public string FromLocationType { get; set; } = "";
[MaxLength(64)] public string FromLocationId { get; set; } = "";
[MaxLength(32)] public string ToLocationType { get; set; } = "";
[MaxLength(64)] public string ToLocationId { get; set; } = "";
}
public sealed record MaterialTypeRequest(
Guid? Id, long? Version, string Code, string Name, string Spec, string Unit, string Category, string BarcodePrefix,
bool Enabled, bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed class ContainerMaterialHistory : WarehouseHistoryBase
{
public Guid? MaterialId { get; set; }
public decimal QuantityDelta { get; set; }
}
public sealed record MaterialRequest(
Guid? Id, long? Version, string Code, string Name, string TypeCode, string Barcode, string Spec, string Unit,
string Category, string LifecycleStatus, bool Enabled, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public static class AreaLayoutModes
{
public const string Flat = "Flat";
public const string Grid = "Grid";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Flat, Grid };
}
public sealed record ContainerLocationRequest(
Guid? Id, long? Version, Guid ContainerId, string LocationType, string LocationId, string Status,
DateTimeOffset? EnteredAt, string Source, string Reason, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public static class LocationKinds
{
public const string Grid = "Grid";
public const string Station = "Station";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Grid, Station };
}
public sealed record BindMaterialRequest(
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, string Source, string Reason,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public static class ContainerLocationTypes
{
public const string Storage = "Storage";
public const string Car = "Car";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Storage, Car };
}
public static class ContainerLocationStatuses
{
public const string Active = "Active";
public const string Locked = "Locked";
public const string Exception = "Exception";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Active, Locked, Exception };
}
public static class ContainerMaterialStatuses
{
public const string Bound = "Bound";
// 兼容旧数据
public const string Loaded = "Loaded";
public const string Unloaded = "Unloaded";
public const string Adjusted = "Adjusted";
public const string Frozen = "Frozen";
public static readonly HashSet<string> ActiveBind = new(StringComparer.OrdinalIgnoreCase) { Bound, Loaded, Adjusted, Frozen };
}
public static class ContainerStatuses
{
public const string EmptyMaterial = "EmptyMaterial";
public const string FullMaterial = "FullMaterial";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { EmptyMaterial, FullMaterial };
}
public static class StorageStatuses
{
public const string Empty = "Empty";
public const string EmptyContainer = "EmptyContainer";
public const string FullContainer = "FullContainer";
public const string Disabled = "Disabled";
// 兼容旧值(读时映射,写时用新值)
public const string Available = "Available";
public const string Idle = "Idle";
public const string Occupied = "Occupied";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{ Empty, EmptyContainer, FullContainer, Disabled };
public static string Normalize(string? status) => status switch
{
Available or Idle => Empty,
Occupied => FullContainer,
_ when string.IsNullOrWhiteSpace(status) => Empty,
_ => status
};
}
public static class StorageTypes
{
public const string Storage = "Storage";
public const string LineSide = "LineSide";
public const string OfflinePoint = "OfflinePoint";
public const string Buffer = "Buffer";
public const string FinishedGoods = "FinishedGoods";
}
public static class MaterialLifecycles
{
public const string Active = "Active";
public const string Archived = "Archived";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Active, Archived };
}
public static class StockEventTypes
{
public const string Bind = "Bind";
public const string Unbind = "Unbind";
public const string ContainerMove = "ContainerMove";
}
public static class WmsTransportTriggerTypes
{
public const string MaterialCall = "MaterialCall";
public const string FinishedGoodsOffline = "FinishedGoodsOffline";
public const string AutoTransfer = "AutoTransfer";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{ MaterialCall, FinishedGoodsOffline, AutoTransfer };
}
public static class WmsTransportTaskStatuses
{
public const string Pending = "Pending";
public const string Reserved = "Reserved";
public const string Dispatched = "Dispatched";
public const string InTransit = "InTransit";
public const string Completed = "Completed";
public const string Failed = "Failed";
public const string Cancelled = "Cancelled";
public static readonly HashSet<string> Active = new(StringComparer.OrdinalIgnoreCase)
{ Pending, Reserved, Dispatched, InTransit };
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{ Pending, Reserved, Dispatched, InTransit, Completed, Failed, Cancelled };
}
public static class WmsReservationStatuses
{
public const string Active = "Active";
public const string Released = "Released";
public const string Expired = "Expired";
}
public sealed class WmsTransportRule : EntityBase
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string TriggerType { get; set; } = WmsTransportTriggerTypes.MaterialCall;
public bool Enabled { get; set; } = true;
public int Priority { get; set; }
public string SourceSelectorJson { get; set; } = "{}";
public string TargetSelectorJson { get; set; } = "{}";
public string TaskOptionsJson { get; set; } = "{}";
}
public sealed class WmsTransportTask : EntityBase
{
[MaxLength(64)] public string BusinessType { get; set; } = "";
public Guid? RuleId { get; set; }
public Guid SourceStorageId { get; set; }
public Guid TargetStorageId { get; set; }
public Guid ContainerId { get; set; }
public Guid? MaterialId { get; set; }
/// <summary>已废弃:单物料实体模型不再使用数量。</summary>
public decimal? Quantity { get; set; }
[MaxLength(32)] public string Status { get; set; } = WmsTransportTaskStatuses.Pending;
[MaxLength(64)] public string DispatchMissionId { get; set; } = "";
[MaxLength(64)] public string DeliveryId { get; set; } = "";
[MaxLength(32)] public string DispatchStatus { get; set; } = "";
[MaxLength(500)] public string Reason { get; set; } = "";
public string SnapshotJson { get; set; } = "{}";
[MaxLength(1000)] public string ErrorMessage { get; set; } = "";
public int TaskPriority { get; set; }
}
public sealed class WmsTransportReservation : EntityBase
{
public Guid TaskId { get; set; }
public Guid ContainerId { get; set; }
public Guid SourceStorageId { get; set; }
public Guid TargetStorageId { get; set; }
[MaxLength(32)] public string Status { get; set; } = WmsReservationStatuses.Active;
public DateTimeOffset? ExpiresAt { get; set; }
}
public sealed class WmsTransportTaskHistory
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TaskId { get; set; }
[MaxLength(32)] public string FromStatus { get; set; } = "";
[MaxLength(32)] public string ToStatus { get; set; } = "";
[MaxLength(128)] public string Operator { get; set; } = "";
public DateTimeOffset OperatedAt { get; set; }
[MaxLength(500)] public string Reason { get; set; } = "";
[MaxLength(1000)] public string ErrorMessage { get; set; } = "";
public string SnapshotJson { get; set; } = "{}";
}
public static class WmsDefaults
{
public const string DefaultWarehouseCode = "DEFAULT";
public const string DefaultWarehouseName = "默认仓库";
}
public sealed record ContainerMaterialRequest(
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, decimal Quantity, string BatchNo, string SerialNo,
string Status, DateTimeOffset? LoadedAt, DateTimeOffset? UnloadedAt, string Source, string Reason,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
+2 -2
View File
@@ -50,12 +50,12 @@ public sealed class WmsReferenceValidator
public async Task<(string Code, string Name)> ResolveLocationSnapshotAsync(string locationType, string locationId)
{
if (!ContainerLocationTypes.All.Contains(locationType))
if (!ContainerLocationTypes.IsDefined(locationType))
throw new InvalidOperationException("位置类型无效");
if (string.IsNullOrWhiteSpace(locationId))
throw new InvalidOperationException("位置 ID 不能为空");
if (string.Equals(locationType, ContainerLocationTypes.Storage, StringComparison.OrdinalIgnoreCase))
if (ContainerLocationTypes.EqualsString(ContainerLocationTypes.Storage, locationType))
{
if (!Guid.TryParse(locationId, out var id)) throw new InvalidOperationException("库位 ID 格式无效");
var s = await _db.Storages.FirstOrDefaultAsync(x => x.Id == id);
+133 -180
View File
@@ -1,6 +1,9 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MiGu.DB.Abstractions.Entities;
using MiGu.DB.Abstractions.Persistence;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
@@ -8,12 +11,20 @@ namespace MiGu.Server.Wms;
public sealed class WmsService
{
private readonly PlatformDbContext _db;
private readonly IUnitOfWork _uow;
private readonly IServiceProvider _services;
private readonly WmsReferenceValidator _refs;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public WmsService(PlatformDbContext db, WmsReferenceValidator refs)
public WmsService(
PlatformDbContext db,
IUnitOfWork uow,
IServiceProvider services,
WmsReferenceValidator refs)
{
_db = db;
_uow = uow;
_services = services;
_refs = refs;
}
@@ -31,7 +42,10 @@ public sealed class WmsService
{
var query = _db.Storages.AsNoTracking().AsQueryable();
if (areaId.HasValue) query = query.Where(x => x.AreaId == areaId.Value);
if (!string.IsNullOrWhiteSpace(locationKind)) query = query.Where(x => x.LocationKind == locationKind);
if (!string.IsNullOrWhiteSpace(locationKind) &&
Enum.TryParse<LocationKind>(locationKind.Trim(), true, out var kind) &&
LocationKinds.All.Contains(kind))
query = query.Where(x => x.LocationKind == kind);
return FilterByKeyword(query.OrderBy(x => x.Code), q).ToListAsync();
}
@@ -44,7 +58,7 @@ public sealed class WmsService
public Task<List<Material>> Materials(string? q = null, string? lifecycle = null, bool? onlyUnbound = null, bool? onlyBound = null)
{
var query = _db.Materials.AsNoTracking().AsQueryable();
var life = string.IsNullOrWhiteSpace(lifecycle) ? MaterialLifecycles.Active : lifecycle.Trim();
var life = MaterialLifecycles.ParseOr(lifecycle);
query = query.Where(x => x.LifecycleStatus == life);
if (onlyUnbound == true || onlyBound == true)
{
@@ -58,7 +72,11 @@ public sealed class WmsService
public Task<List<ContainerLocation>> ContainerLocations(string? locationType = null, string? q = null)
{
var query = _db.ContainerLocations.AsNoTracking().OrderBy(x => x.ContainerId).AsQueryable();
if (!string.IsNullOrWhiteSpace(locationType)) query = query.Where(x => x.LocationType == locationType);
if (!string.IsNullOrWhiteSpace(locationType) && ContainerLocationTypes.IsDefined(locationType))
{
var lt = ContainerLocationTypes.ParseOr(locationType);
query = query.Where(x => x.LocationType == lt);
}
return FilterByKeyword(query, q).ToListAsync();
}
@@ -69,61 +87,71 @@ public sealed class WmsService
return FilterByKeyword(query.OrderBy(x => x.ContainerId), q).ToListAsync();
}
/// <summary>
/// 库存物料列表:筛选/排序下推到 SQL。
/// 库位:优先 ContainerLocation.StorageId;未回填时用 LocationId 与库位 Id 的存储字符串(Guid "D")匹配。
/// </summary>
public async Task<List<InventoryMaterialRow>> InventoryMaterials(Guid? areaId = null, Guid? storageId = null, string? q = null)
{
var binds = await _db.ContainerMaterials.AsNoTracking().ToListAsync();
var materials = await _db.Materials.AsNoTracking().ToDictionaryAsync(x => x.Id);
var locations = await _db.ContainerLocations.AsNoTracking().ToDictionaryAsync(x => x.ContainerId);
var storages = await _db.Storages.AsNoTracking().ToDictionaryAsync(x => x.Id);
var areas = await _db.WarehouseAreas.AsNoTracking().ToDictionaryAsync(x => x.Id);
var containers = await _db.Containers.AsNoTracking().ToDictionaryAsync(x => x.Id);
var query =
from b in _db.ContainerMaterials.AsNoTracking()
join mat in _db.Materials.AsNoTracking() on b.MaterialId equals mat.Id
from ctn in _db.Containers.AsNoTracking().Where(c => c.Id == b.ContainerId).DefaultIfEmpty()
from loc in _db.ContainerLocations.AsNoTracking().Where(l => l.ContainerId == b.ContainerId).DefaultIfEmpty()
from st in _db.Storages.AsNoTracking().Where(s =>
loc != null &&
loc.LocationType == ContainerLocationType.Storage &&
(loc.StorageId == s.Id ||
(loc.StorageId == null && loc.LocationId == EF.Property<string>(s, nameof(Storage.Id))))).DefaultIfEmpty()
from area in _db.WarehouseAreas.AsNoTracking().Where(a => st != null && a.Id == st.AreaId).DefaultIfEmpty()
select new { b, mat, ctn, loc, st, area };
var rows = new List<InventoryMaterialRow>();
foreach (var b in binds)
if (storageId.HasValue)
query = query.Where(x => x.st != null && x.st.Id == storageId.Value);
if (areaId.HasValue)
query = query.Where(x => x.area != null && x.area.Id == areaId.Value);
if (!string.IsNullOrWhiteSpace(q))
{
if (!materials.TryGetValue(b.MaterialId, out var mat)) continue;
locations.TryGetValue(b.ContainerId, out var loc);
Storage? storage = null;
WarehouseArea? area = null;
if (loc != null && loc.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(loc.LocationId, out var sid))
storages.TryGetValue(sid, out storage);
if (storage != null) areas.TryGetValue(storage.AreaId, out area);
containers.TryGetValue(b.ContainerId, out var ctn);
if (storageId.HasValue && storage?.Id != storageId.Value) continue;
if (areaId.HasValue && area?.Id != areaId.Value) continue;
if (!string.IsNullOrWhiteSpace(q))
{
var s = q.Trim();
if (!(mat.Code.Contains(s) || mat.Name.Contains(s) || (ctn?.Code.Contains(s) ?? false) || (storage?.Code.Contains(s) ?? false)))
continue;
}
rows.Add(new InventoryMaterialRow(
mat.Id, mat.Code, mat.Name, mat.Barcode, mat.TypeCode,
b.ContainerId, ctn?.Code ?? "", ctn?.Name ?? "",
storage?.Id, storage?.Code ?? "", storage?.Name ?? "",
area?.Id, area?.Code ?? "", area?.Name ?? "",
loc?.LocationType ?? "", b.BoundAt));
var s = q.Trim();
query = query.Where(x =>
x.mat.Code.Contains(s) || x.mat.Name.Contains(s) ||
(x.ctn != null && (x.ctn.Code.Contains(s) || x.ctn.Name.Contains(s))) ||
(x.st != null && x.st.Code.Contains(s)));
}
return rows.OrderBy(x => x.MaterialCode).ToList();
return await query
.OrderBy(x => x.mat.Code)
.Select(x => new InventoryMaterialRow(
x.mat.Id, x.mat.Code, x.mat.Name, x.mat.Barcode, x.mat.TypeCode,
x.b.ContainerId, x.ctn != null ? x.ctn.Code : "", x.ctn != null ? x.ctn.Name : "",
x.st != null ? x.st.Id : null, x.st != null ? x.st.Code : "", x.st != null ? x.st.Name : "",
x.area != null ? x.area.Id : null, x.area != null ? x.area.Code : "", x.area != null ? x.area.Name : "",
x.loc != null ? x.loc.LocationType.ToString() : "", x.b.BoundAt))
.ToListAsync();
}
/// <summary>
/// 库存事件:条件与 OrderBy/Take(500) 均下推;非法 eventType 直接返回空,避免全表拉取后再过滤。
/// </summary>
public async Task<List<StockEvent>> StockEvents(string? eventType = null, Guid? materialId = null, Guid? containerId = null)
{
var query = _db.StockEvents.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(eventType)) query = query.Where(x => x.EventType == eventType);
if (!string.IsNullOrWhiteSpace(eventType))
{
if (Enum.TryParse<StockEventType>(eventType.Trim(), true, out var et))
query = query.Where(x => x.EventType == et);
else
return [];
}
if (materialId.HasValue) query = query.Where(x => x.MaterialId == materialId.Value);
if (containerId.HasValue) query = query.Where(x => x.ContainerId == containerId.Value);
var rows = await query.ToListAsync();
return rows.OrderByDescending(x => x.OperatedAt).Take(500).ToList();
return await query.OrderByDescending(x => x.OperatedAt).Take(500).ToListAsync();
}
public async Task<Warehouse> SaveWarehouse(MasterDataRequest req, string actor)
{
Warehouse entity;
if (req.Id.HasValue) entity = await FindEditable(_db.Warehouses, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<Warehouse>(req.Id.Value, req.Version);
else
{
entity = new Warehouse();
@@ -137,7 +165,7 @@ public sealed class WmsService
entity.Enabled = req.Enabled;
entity.SortOrder = req.SortOrder;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
@@ -150,7 +178,7 @@ public sealed class WmsService
await _refs.EnsureWarehouseAsync(warehouseId);
WarehouseArea entity;
if (req.Id.HasValue) entity = await FindEditable(_db.WarehouseAreas, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<WarehouseArea>(req.Id.Value, req.Version);
else
{
entity = new WarehouseArea();
@@ -162,24 +190,24 @@ public sealed class WmsService
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.Type = req.Type.TrimOr("Storage");
entity.LayoutMode = AreaLayoutModes.All.Contains(req.LayoutMode) ? req.LayoutMode : AreaLayoutModes.Flat;
entity.LayoutMode = AreaLayoutModes.ParseOr(req.LayoutMode);
entity.State = req.State.TrimOr("Default");
entity.Enabled = req.Enabled;
entity.SortOrder = req.SortOrder;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
public async Task<Storage> SaveStorage(StorageRequest req, string actor)
{
await _refs.EnsureAreaAsync(req.AreaId);
var kind = LocationKinds.All.Contains(req.LocationKind) ? req.LocationKind : LocationKinds.Station;
var kind = LocationKinds.ParseOr(req.LocationKind);
if (kind == LocationKinds.Grid)
await EnsureGridCoordUnique(req.AreaId, req.ColumnNo, req.LevelNo, req.DepthNo, req.Id);
Storage entity;
if (req.Id.HasValue) entity = await FindEditable(_db.Storages, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<Storage>(req.Id.Value, req.Version);
else
{
entity = new Storage();
@@ -199,7 +227,7 @@ public sealed class WmsService
entity.SiteCode = req.SiteCode.TrimOr(entity.SiteId);
entity.Barcode = req.Barcode.TrimOr("");
entity.Capacity = 1;
var status = StorageStatuses.Normalize(req.Status.TrimOr(StorageStatuses.Empty));
var status = StorageStatuses.Normalize(req.Status);
if (status == StorageStatuses.Disabled || req.Enabled == false)
entity.Status = req.Enabled ? status : StorageStatuses.Disabled;
else if (!string.IsNullOrWhiteSpace(req.Status) && StorageStatuses.All.Contains(status))
@@ -211,7 +239,7 @@ public sealed class WmsService
entity.AllowOutbound = req.AllowOutbound;
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
if (entity.Status != StorageStatuses.Disabled)
await SyncOccupancyStatus(storageId: entity.Id);
return entity;
@@ -262,28 +290,28 @@ public sealed class WmsService
_db.Storages.Add(entity);
created++;
}
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return created;
}
public async Task<Storage> SetStorageLock(Guid id, bool isLock, long? version, string actor)
{
var entity = await FindEditable(_db.Storages, id, version);
var entity = await FindEditable<Storage>(id, version);
entity.IsLock = isLock;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
public async Task<Storage> SetStorageEnabled(Guid id, bool enabled, long? version, string actor)
{
var entity = await FindEditable(_db.Storages, id, version);
var entity = await FindEditable<Storage>(id, version);
entity.Enabled = enabled;
entity.Status = enabled
? (entity.Status == StorageStatuses.Disabled ? StorageStatuses.Empty : entity.Status)
: StorageStatuses.Disabled;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
if (enabled) await SyncOccupancyStatus(storageId: id);
return entity;
}
@@ -291,7 +319,7 @@ public sealed class WmsService
public async Task<Container> SaveContainer(ContainerRequest req, string actor)
{
Container entity;
if (req.Id.HasValue) entity = await FindEditable(_db.Containers, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<Container>(req.Id.Value, req.Version);
else
{
entity = new Container();
@@ -303,15 +331,14 @@ public sealed class WmsService
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.ContainerType = req.Type.TrimOr("Box");
var status = req.Status.TrimOr(ContainerStatuses.EmptyMaterial);
entity.Status = ContainerStatuses.All.Contains(status) ? status : ContainerStatuses.EmptyMaterial;
entity.Status = ContainerStatuses.ParseOr(req.Status);
entity.Barcode = req.Barcode.TrimOr("");
entity.Length = req.Length;
entity.Width = req.Width;
entity.Height = req.Height;
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: entity.Id);
return entity;
}
@@ -319,7 +346,7 @@ public sealed class WmsService
public async Task<MaterialType> SaveMaterialType(MaterialTypeRequest req, string actor)
{
MaterialType entity;
if (req.Id.HasValue) entity = await FindEditable(_db.MaterialTypes, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<MaterialType>(req.Id.Value, req.Version);
else
{
entity = new MaterialType();
@@ -335,7 +362,7 @@ public sealed class WmsService
entity.BarcodePrefix = req.BarcodePrefix.TrimOr("");
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
@@ -344,7 +371,7 @@ public sealed class WmsService
if (!string.IsNullOrWhiteSpace(req.TypeCode))
await _refs.EnsureMaterialTypeCodeAsync(req.TypeCode);
Material entity;
if (req.Id.HasValue) entity = await FindEditable(_db.Materials, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<Material>(req.Id.Value, req.Version);
else
{
entity = new Material();
@@ -361,23 +388,22 @@ public sealed class WmsService
entity.Spec = req.Spec.TrimOr("");
entity.Unit = req.Unit.TrimOr("pcs");
entity.Category = req.Category.TrimOr("");
var life = req.LifecycleStatus.TrimOr(MaterialLifecycles.Active);
entity.LifecycleStatus = MaterialLifecycles.All.Contains(life) ? life : MaterialLifecycles.Active;
entity.LifecycleStatus = MaterialLifecycles.ParseOr(req.LifecycleStatus);
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
public async Task<Material> ArchiveMaterial(Guid id, long? version, string actor)
{
var entity = await FindEditable(_db.Materials, id, version);
var entity = await FindEditable<Material>(id, version);
if (await _db.ContainerMaterials.AnyAsync(x => x.MaterialId == id))
throw new InvalidOperationException("物料仍在绑定中,不能归档");
entity.LifecycleStatus = MaterialLifecycles.Archived;
entity.UnboundAt ??= DateTimeOffset.UtcNow;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
@@ -396,13 +422,12 @@ public sealed class WmsService
entity.DeletedBy = actor;
entity.UpdatedBy = actor;
}
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
}
public async Task DeleteEntity<T>(Guid id, long? version, string actor) where T : EntityBase
{
var set = _db.Set<T>();
var entity = await FindEditable(set, id, version);
var entity = await FindEditable<T>(id, version);
if (typeof(T) == typeof(WarehouseArea))
{
if (await _db.Storages.AnyAsync(x => x.AreaId == id))
@@ -423,19 +448,22 @@ public sealed class WmsService
entity.DeletedAt = DateTimeOffset.UtcNow;
entity.DeletedBy = actor;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
}
public async Task<ContainerLocation> BindOrTransferLocation(ContainerLocationRequest req, string actor)
{
await _refs.EnsureContainerAsync(req.ContainerId);
var (code, name) = await _refs.ResolveLocationSnapshotAsync(req.LocationType, req.LocationId);
if (!ContainerLocationStatuses.All.Contains(req.Status))
if (!ContainerLocationStatuses.IsDefined(req.Status))
throw new InvalidOperationException("容器位置状态无效");
var locationType = ContainerLocationTypes.ParseOr(req.LocationType);
var locationStatus = ContainerLocationStatuses.ParseOr(req.Status);
Guid? fromStorageId = null;
Guid? toStorageId = null;
if (string.Equals(req.LocationType, ContainerLocationTypes.Storage, StringComparison.OrdinalIgnoreCase) &&
if (locationType == ContainerLocationTypes.Storage &&
Guid.TryParse(req.LocationId, out var targetStorageId))
{
toStorageId = targetStorageId;
@@ -455,7 +483,9 @@ public sealed class WmsService
var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == req.ContainerId);
var before = current == null ? null : Snapshot(current);
if (before != null && before.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(before.LocationId, out var fs))
if (before != null &&
ContainerLocationTypes.EqualsString(ContainerLocationTypes.Storage, before.LocationType) &&
Guid.TryParse(before.LocationId, out var fs))
fromStorageId = fs;
var now = DateTimeOffset.UtcNow;
@@ -471,11 +501,12 @@ public sealed class WmsService
EnsureUnlocked(current);
}
current.LocationType = req.LocationType;
current.LocationType = locationType;
current.LocationId = req.LocationId.Trim();
current.StorageId = toStorageId;
current.LocationCode = code;
current.LocationName = name;
current.Status = req.Status;
current.Status = locationStatus;
current.EnteredAt = req.EnteredAt ?? now;
ApplyCommon(current, req, actor);
@@ -486,7 +517,7 @@ public sealed class WmsService
EventType = before == null ? "Bind" : "Transfer",
FromLocationType = before?.LocationType ?? "",
FromLocationId = before?.LocationId ?? "",
ToLocationType = current.LocationType,
ToLocationType = current.LocationType.ToString(),
ToLocationId = current.LocationId,
BeforeJson = before == null ? "{}" : JsonSerializer.Serialize(before, _json),
AfterJson = JsonSerializer.Serialize(Snapshot(current), _json),
@@ -499,7 +530,7 @@ public sealed class WmsService
});
await AddContainerMoveEventAsync(req.ContainerId, fromStorageId, toStorageId, actor, req.Reason.TrimOr(""), now);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: req.ContainerId, storageId: fromStorageId);
await SyncOccupancyStatus(storageId: toStorageId);
return current;
@@ -520,7 +551,7 @@ public sealed class WmsService
RelationId = current.Id,
ContainerId = current.ContainerId,
EventType = "Unbind",
FromLocationType = current.LocationType,
FromLocationType = current.LocationType.ToString(),
FromLocationId = current.LocationId,
BeforeJson = JsonSerializer.Serialize(before, _json),
AfterJson = "{}",
@@ -531,7 +562,7 @@ public sealed class WmsService
});
await AddContainerMoveEventAsync(containerId, fromStorageId, null, actor, reason, now);
_db.ContainerLocations.Remove(current);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: containerId, storageId: fromStorageId);
}
@@ -578,7 +609,7 @@ public sealed class WmsService
material.UnboundAt = null;
await AddBindUnbindEventAsync(StockEventTypes.Bind, material, req.ContainerId, actor, req.Reason.TrimOr(""), now);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: req.ContainerId);
return current;
}
@@ -620,7 +651,7 @@ public sealed class WmsService
}
_db.ContainerMaterials.Remove(current);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: containerId);
}
@@ -666,7 +697,7 @@ public sealed class WmsService
}
}
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
}
public async Task<Warehouse> EnsureDefaultWarehouseAsync(string actor = "system")
@@ -683,7 +714,7 @@ public sealed class WmsService
};
StampCreate(wh, actor);
_db.Warehouses.Add(wh);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return wh;
}
@@ -692,44 +723,26 @@ public sealed class WmsService
var wh = await EnsureDefaultWarehouseAsync(actor);
var areas = await _db.WarehouseAreas.Where(x => x.WarehouseId == Guid.Empty).ToListAsync();
foreach (var a in areas)
{
a.WarehouseId = wh.Id;
if (string.IsNullOrWhiteSpace(a.LayoutMode)) a.LayoutMode = AreaLayoutModes.Flat;
}
var storages = await _db.Storages.ToListAsync();
foreach (var s in storages)
{
if (string.IsNullOrWhiteSpace(s.LocationKind))
s.LocationKind = LocationKinds.Station;
if (s.LevelNo <= 0) s.LevelNo = 1;
if (s.DepthNo <= 0) s.DepthNo = 1;
if (string.IsNullOrWhiteSpace(s.SiteCode)) s.SiteCode = s.SiteId;
s.Status = StorageStatuses.Normalize(s.Status) switch
{
StorageStatuses.Available or StorageStatuses.Idle => StorageStatuses.Empty,
StorageStatuses.Occupied => StorageStatuses.FullContainer,
var x => x
};
if (!StorageStatuses.All.Contains(s.Status))
s.Status = StorageStatuses.Empty;
if (!s.Enabled) s.Status = StorageStatuses.Disabled;
}
var containers = await _db.Containers.ToListAsync();
foreach (var c in containers)
{
c.Status = c.Status switch
{
"Idle" or "Empty" => ContainerStatuses.EmptyMaterial,
"Loaded" => ContainerStatuses.FullMaterial,
_ when ContainerStatuses.All.Contains(c.Status) => c.Status,
_ => ContainerStatuses.EmptyMaterial
};
if (!ContainerStatuses.All.Contains(c.Status))
c.Status = ContainerStatuses.EmptyMaterial;
}
var materials = await _db.Materials.Where(x => string.IsNullOrWhiteSpace(x.LifecycleStatus)).ToListAsync();
foreach (var m in materials)
m.LifecycleStatus = MaterialLifecycles.Active;
var binds = await _db.ContainerMaterials.ToListAsync();
foreach (var b in binds)
{
@@ -739,7 +752,7 @@ public sealed class WmsService
b.Status = ContainerMaterialStatuses.Bound;
}
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
foreach (var s in storages.Where(x => x.Status != StorageStatuses.Disabled))
await SyncOccupancyStatus(storageId: s.Id);
@@ -773,13 +786,14 @@ public sealed class WmsService
if (exists) throw new InvalidOperationException("同库区网格坐标已存在");
}
private async Task AddBindUnbindEventAsync(string eventType, Material material, Guid containerId, string actor, string reason, DateTimeOffset now)
private async Task AddBindUnbindEventAsync(StockEventType eventType, Material material, Guid containerId, string actor, string reason, DateTimeOffset now)
{
var ctn = await _db.Containers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == containerId);
var loc = await _db.ContainerLocations.AsNoTracking().FirstOrDefaultAsync(x => x.ContainerId == containerId);
Storage? storage = null;
WarehouseArea? area = null;
if (loc is { LocationType: ContainerLocationTypes.Storage } && Guid.TryParse(loc.LocationId, out var sid))
if (loc is { LocationType: ContainerLocationType.Storage } &&
(loc.StorageId is { } sid || Guid.TryParse(loc.LocationId, out sid)))
{
storage = await _db.Storages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == sid);
if (storage != null)
@@ -843,23 +857,21 @@ public sealed class WmsService
});
}
private async Task<T> FindEditable<T>(DbSet<T> set, Guid id, long? version) where T : EntityBase
{
var entity = await set.FirstOrDefaultAsync(x => x.Id == id) ?? throw new InvalidOperationException("数据不存在");
EnsureVersion(entity, version);
EnsureUnlocked(entity);
return entity;
}
private Task<T> FindEditable<T>(Guid id, long? version)
where T : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
=> _services.GetRequiredService<IEditableRepository<T>>().GetEditableAsync(id, version);
private static void EnsureVersion(EntityBase entity, long? version)
{
if (version.HasValue && entity.Version != version.Value)
throw new InvalidOperationException("数据已被其他用户修改,请刷新后重试");
throw new MiGu.DB.Abstractions.Exceptions.ConcurrencyConflictException(
entity.GetType().Name, entity.Id, version);
}
private static void EnsureUnlocked(EntityBase entity)
{
if (entity.IsLock) throw new InvalidOperationException("数据已锁定,不能修改");
if (entity.IsLock)
throw new MiGu.DB.Abstractions.Exceptions.EntityLockedException(entity.GetType().Name, entity.Id);
}
private static void StampCreate(EntityBase entity, string actor)
@@ -909,73 +921,14 @@ public sealed class WmsService
}
private static ContainerLocationSnapshot Snapshot(ContainerLocation x) => new(
x.Id, x.ContainerId, x.LocationType, x.LocationId, x.LocationCode, x.LocationName, x.Status, x.EnteredAt, x.Version);
x.Id, x.ContainerId, x.LocationType.ToString(), x.LocationId, x.LocationCode, x.LocationName,
x.Status.ToString(), x.EnteredAt, x.Version);
private static ContainerMaterialSnapshot Snapshot(ContainerMaterial x) => new(
x.Id, x.ContainerId, x.MaterialId, x.Quantity, x.BatchNo, x.SerialNo, x.Status, x.BoundAt, x.LoadedAt, x.UnloadedAt, x.Version);
x.Id, x.ContainerId, x.MaterialId, x.Quantity, x.BatchNo, x.SerialNo, x.Status.ToString(),
x.BoundAt, x.LoadedAt, x.UnloadedAt, x.Version);
}
public sealed record InventoryMaterialRow(
Guid MaterialId, string MaterialCode, string MaterialName, string MaterialBarcode, string TypeCode,
Guid ContainerId, string ContainerCode, string ContainerName,
Guid? StorageId, string StorageCode, string StorageName,
Guid? AreaId, string AreaCode, string AreaName,
string LocationType, DateTimeOffset BoundAt);
public sealed record ContainerLocationSnapshot(
Guid Id, Guid ContainerId, string LocationType, string LocationId, string LocationCode, string LocationName,
string Status, DateTimeOffset EnteredAt, long Version);
public sealed record ContainerMaterialSnapshot(
Guid Id, Guid ContainerId, Guid MaterialId, decimal Quantity, string BatchNo, string SerialNo, string Status,
DateTimeOffset BoundAt, DateTimeOffset LoadedAt, DateTimeOffset? UnloadedAt, long Version);
public abstract record CommonRequest(Guid? Id, long? Version, bool IsLock, string Remark, string Extend);
public sealed record MasterDataRequest(
Guid? Id, long? Version, string Code, string Name, string Type, string Status, bool Enabled, int SortOrder,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record AreaRequest(
Guid? Id, long? Version, Guid? WarehouseId, string Code, string Name, string Type, string LayoutMode, string State,
bool Enabled, int SortOrder, bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record StorageRequest(
Guid? Id, long? Version, Guid AreaId, string Code, string Name, string StorageType, string LocationKind,
int ColumnNo, int LevelNo, int DepthNo, string SiteId, string SiteCode, string Barcode, int Capacity,
string Status, string Usage, int Priority, string ZoneCode, bool AllowInbound, bool AllowOutbound, bool Enabled,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record GenerateBinsRequest(int ColumnFrom, int ColumnTo, int LevelFrom, int LevelTo, int DepthFrom, int DepthTo, string? CodePattern);
public sealed record ContainerRequest(
Guid? Id, long? Version, Guid? AreaId, string Code, string Name, string Type, string Status, string Barcode,
double Length, double Width, double Height, bool Enabled, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record MaterialTypeRequest(
Guid? Id, long? Version, string Code, string Name, string Spec, string Unit, string Category, string BarcodePrefix,
bool Enabled, bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record MaterialRequest(
Guid? Id, long? Version, string Code, string Name, string TypeCode, string Barcode, string Spec, string Unit,
string Category, string LifecycleStatus, bool Enabled, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record ContainerLocationRequest(
Guid? Id, long? Version, Guid ContainerId, string LocationType, string LocationId, string Status,
DateTimeOffset? EnteredAt, string Source, string Reason, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record BindMaterialRequest(
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, string Source, string Reason,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record ContainerMaterialRequest(
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, decimal Quantity, string BatchNo, string SerialNo,
string Status, DateTimeOffset? LoadedAt, DateTimeOffset? UnloadedAt, string Source, string Reason,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public static class WmsStringExtensions
{
public static string TrimOr(this string? value, string fallback) =>
+19
View File
@@ -0,0 +1,19 @@
// Server 侧状态别名:把 MiGu.DB 的复数辅助类导入为短名,兼容业务代码中的 StorageStatuses / LocationKinds 等写法。
// 枚举类型本身通过下方 global using Domains 命名空间直接可用(如 StorageStatus、LocationKind)。
global using MiGu.DB.Domains.Wms;
global using MiGu.DB.Domains.Transport;
global using AreaLayoutModes = MiGu.DB.Domains.Wms.AreaLayoutModes;
global using StorageTypeCodes = MiGu.DB.Domains.Wms.StorageTypeCodes;
global using LocationKinds = MiGu.DB.Domains.Wms.LocationKinds;
global using StorageStatuses = MiGu.DB.Domains.Wms.StorageStatuses;
global using ContainerStatuses = MiGu.DB.Domains.Wms.ContainerStatuses;
global using MaterialLifecycles = MiGu.DB.Domains.Wms.MaterialLifecycles;
global using ContainerLocationTypes = MiGu.DB.Domains.Wms.ContainerLocationTypes;
global using ContainerLocationStatuses = MiGu.DB.Domains.Wms.ContainerLocationStatuses;
global using ContainerMaterialStatuses = MiGu.DB.Domains.Wms.ContainerMaterialStatuses;
global using StockEventTypes = MiGu.DB.Domains.Wms.StockEventTypes;
global using WmsDefaults = MiGu.DB.Domains.Wms.WmsDefaults;
global using WmsTransportTaskStatuses = MiGu.DB.Domains.Transport.WmsTransportTaskStatuses;
global using WmsTransportTriggerTypes = MiGu.DB.Domains.Transport.WmsTransportTriggerTypes;
global using WmsReservationStatuses = MiGu.DB.Domains.Transport.WmsReservationStatuses;
global using WmsDispatchStatuses = MiGu.DB.Domains.Transport.WmsDispatchStatuses;
+19 -12
View File
@@ -13,11 +13,12 @@ public sealed class WmsTransportPlanner
public async Task<TransportCandidatePreview> PreviewAsync(WmsTransportRequest request)
{
if (!WmsTransportTriggerTypes.All.Contains(request.TriggerType))
if (!WmsTransportTriggerTypes.IsDefined(request.TriggerType))
throw new InvalidOperationException("触发类型无效");
var trigger = WmsTransportTriggerTypes.ParseOr(request.TriggerType);
var ctx = await BuildContextAsync();
var rules = await LoadRulesAsync(request);
var rules = await LoadRulesAsync(request, trigger);
var ruleResults = new List<RulePreviewResult>();
var candidates = new List<TransportCandidatePair>();
@@ -26,7 +27,7 @@ public sealed class WmsTransportPlanner
var sourceSelector = TransportSelectorParser.ParseSelector(rule.SourceSelectorJson);
var targetSelector = TransportSelectorParser.ParseSelector(rule.TargetSelectorJson);
var options = TransportSelectorParser.ParseTaskOptions(rule.TaskOptionsJson);
var ruleCandidates = BuildCandidates(ctx, request, rule, sourceSelector, targetSelector, options);
var ruleCandidates = BuildCandidates(ctx, request, trigger, rule, sourceSelector, targetSelector, options);
var rejectReasons = ruleCandidates.Count == 0
? new List<string> { "未找到满足条件的起点/终点组合" }
: new List<string>();
@@ -52,10 +53,10 @@ public sealed class WmsTransportPlanner
return preview.Candidates.FirstOrDefault();
}
private async Task<List<WmsTransportRule>> LoadRulesAsync(WmsTransportRequest request)
private async Task<List<WmsTransportRule>> LoadRulesAsync(WmsTransportRequest request, WmsTransportTriggerType trigger)
{
var query = _db.WmsTransportRules.AsNoTracking()
.Where(x => x.Enabled && x.TriggerType == request.TriggerType);
.Where(x => x.Enabled && x.TriggerType == trigger);
if (request.RuleId.HasValue)
query = query.Where(x => x.Id == request.RuleId.Value);
return await query.OrderByDescending(x => x.Priority).ThenBy(x => x.Code).ToListAsync();
@@ -94,17 +95,18 @@ public sealed class WmsTransportPlanner
private List<TransportCandidatePair> BuildCandidates(
PlannerContext ctx,
WmsTransportRequest request,
WmsTransportTriggerType trigger,
WmsTransportRule rule,
TransportSelector sourceSelector,
TransportSelector targetSelector,
TransportTaskOptions options)
{
var results = new List<TransportCandidatePair>();
var sources = BuildSourceCandidates(ctx, request, sourceSelector, options);
var sources = BuildSourceCandidates(ctx, request, trigger, sourceSelector, options);
foreach (var src in sources)
{
var targets = BuildTargetCandidates(ctx, request, targetSelector, options, src.StorageId);
var targets = BuildTargetCandidates(ctx, request, trigger, targetSelector, options, src.StorageId);
foreach (var tgt in targets)
{
var score = rule.Priority * 1000 + src.Score + tgt.Score;
@@ -124,6 +126,7 @@ public sealed class WmsTransportPlanner
private List<SourceCandidate> BuildSourceCandidates(
PlannerContext ctx,
WmsTransportRequest request,
WmsTransportTriggerType trigger,
TransportSelector selector,
TransportTaskOptions options)
{
@@ -145,7 +148,7 @@ public sealed class WmsTransportPlanner
continue;
if (request.RequestSiteId is { Length: > 0 } siteId &&
string.Equals(request.TriggerType, WmsTransportTriggerTypes.FinishedGoodsOffline, StringComparison.OrdinalIgnoreCase) &&
trigger == WmsTransportTriggerTypes.FinishedGoodsOffline &&
!string.Equals(storage.SiteId, siteId, StringComparison.OrdinalIgnoreCase))
continue;
@@ -162,6 +165,7 @@ public sealed class WmsTransportPlanner
private List<TargetCandidate> BuildTargetCandidates(
PlannerContext ctx,
WmsTransportRequest request,
WmsTransportTriggerType trigger,
TransportSelector selector,
TransportTaskOptions options,
Guid sourceStorageId)
@@ -176,7 +180,7 @@ public sealed class WmsTransportPlanner
if (ctx.ReservedTargetStorages.Contains(storage.Id)) continue;
if (request.RequestSiteId is { Length: > 0 } siteId &&
string.Equals(request.TriggerType, WmsTransportTriggerTypes.MaterialCall, StringComparison.OrdinalIgnoreCase) &&
trigger == WmsTransportTriggerTypes.MaterialCall &&
selector.Storage?.SiteIds is { Count: 0 } &&
!string.Equals(storage.SiteId, siteId, StringComparison.OrdinalIgnoreCase))
continue;
@@ -200,7 +204,8 @@ public sealed class WmsTransportPlanner
{
if (request.MaterialId.HasValue && row.MaterialId != request.MaterialId.Value) continue;
if (filter?.MaterialIds is { Count: > 0 } ids && !ids.Contains(row.MaterialId.ToString("D"))) continue;
if (filter?.StatusIn is { Count: > 0 } st && !st.Contains(row.Status, StringComparer.OrdinalIgnoreCase)) continue;
if (filter?.StatusIn is { Count: > 0 } st &&
!st.Contains(row.Status.ToString(), StringComparer.OrdinalIgnoreCase)) continue;
if (filter?.MinQuantity is { } min && row.Quantity < min) continue;
if (request.Quantity is { } reqQty && row.Quantity < reqQty) continue;
if (request.BatchNo is { Length: > 0 } batch && !string.Equals(row.BatchNo, batch, StringComparison.OrdinalIgnoreCase)) continue;
@@ -220,7 +225,8 @@ public sealed class WmsTransportPlanner
if (filter.AreaIds is { Count: > 0 } && !filter.AreaIds.Contains(storage.AreaId.ToString("D"))) return false;
if (filter.StorageTypes is { Count: > 0 } && !filter.StorageTypes.Contains(storage.StorageType, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.ZoneCodes is { Count: > 0 } && !filter.ZoneCodes.Contains(storage.ZoneCode, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.StatusIn is { Count: > 0 } && !filter.StatusIn.Contains(storage.Status, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.StatusIn is { Count: > 0 } &&
!filter.StatusIn.Contains(storage.Status.ToString(), StringComparer.OrdinalIgnoreCase)) return false;
if (filter.SiteIds is { Count: > 0 } && !filter.SiteIds.Contains(storage.SiteId, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.AllowInbound == true && !storage.AllowInbound) return false;
if (filter.AllowOutbound == true && !storage.AllowOutbound) return false;
@@ -236,7 +242,8 @@ public sealed class WmsTransportPlanner
{
if (filter == null) return true;
if (filter.ContainerTypes is { Count: > 0 } && !filter.ContainerTypes.Contains(container.ContainerType, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.StatusIn is { Count: > 0 } && !filter.StatusIn.Contains(container.Status, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.StatusIn is { Count: > 0 } &&
!filter.StatusIn.Contains(container.Status.ToString(), StringComparer.OrdinalIgnoreCase)) return false;
if (filter.ExcludeReserved &&
(ctx.ReservedContainers.Contains(container.Id) || ctx.TaskReservedContainers.Contains(container.Id)))
return false;
+24 -30
View File
@@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore;
using MiGu.DB.Abstractions.Exceptions;
using MiGu.DB.Abstractions.Persistence;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
@@ -6,14 +8,26 @@ namespace MiGu.Server.Wms;
public sealed class WmsTransportRuleService
{
private readonly PlatformDbContext _db;
private readonly IEditableRepository<WmsTransportRule> _rules;
private readonly IUnitOfWork _uow;
public WmsTransportRuleService(PlatformDbContext db) => _db = db;
public WmsTransportRuleService(
PlatformDbContext db,
IEditableRepository<WmsTransportRule> rules,
IUnitOfWork uow)
{
_db = db;
_rules = rules;
_uow = uow;
}
public Task<List<WmsTransportRule>> ListAsync(string? triggerType = null, string? q = null)
{
var query = _db.WmsTransportRules.AsNoTracking().OrderByDescending(x => x.Priority).ThenBy(x => x.Code).AsQueryable();
if (!string.IsNullOrWhiteSpace(triggerType))
query = query.Where(x => x.TriggerType == triggerType);
if (!string.IsNullOrWhiteSpace(triggerType) &&
Enum.TryParse<WmsTransportTriggerType>(triggerType.Trim(), true, out var tt) &&
WmsTransportTriggerTypes.All.Contains(tt))
query = query.Where(x => x.TriggerType == tt);
if (!string.IsNullOrWhiteSpace(q))
{
var s = q.Trim();
@@ -24,12 +38,12 @@ public sealed class WmsTransportRuleService
public async Task<WmsTransportRule> SaveAsync(TransportRuleRequest req, string actor)
{
if (!WmsTransportTriggerTypes.All.Contains(req.TriggerType))
if (!WmsTransportTriggerTypes.IsDefined(req.TriggerType))
throw new InvalidOperationException("触发类型无效");
WmsTransportRule entity;
if (req.Id.HasValue)
entity = await FindEditable(req.Id.Value, req.Version);
entity = await _rules.GetEditableAsync(req.Id.Value, req.Version);
else
{
entity = new WmsTransportRule();
@@ -37,44 +51,24 @@ public sealed class WmsTransportRuleService
_db.WmsTransportRules.Add(entity);
}
await EnsureUniqueCode(req.Code, entity.Id);
await _rules.EnsureUniqueAsync(x => x.Code == req.Code.Trim() && x.Id != entity.Id, "规则编码已存在");
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.TriggerType = req.TriggerType.Trim();
entity.TriggerType = WmsTransportTriggerTypes.ParseOr(req.TriggerType);
entity.Enabled = req.Enabled;
entity.Priority = req.Priority;
entity.SourceSelectorJson = TransportSelectorParser.NormalizeSelectorJson(req.SourceSelectorJson);
entity.TargetSelectorJson = TransportSelectorParser.NormalizeSelectorJson(req.TargetSelectorJson);
entity.TaskOptionsJson = TransportSelectorParser.NormalizeTaskOptionsJson(req.TaskOptionsJson);
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
public async Task DeleteAsync(Guid id, long? version, string actor)
{
var entity = await FindEditable(id, version);
entity.IsDeleted = true;
entity.DeletedAt = DateTimeOffset.UtcNow;
entity.DeletedBy = actor;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
}
private async Task<WmsTransportRule> FindEditable(Guid id, long? version)
{
var entity = await _db.WmsTransportRules.FirstOrDefaultAsync(x => x.Id == id)
?? throw new InvalidOperationException("规则不存在");
if (version.HasValue && entity.Version != version.Value)
throw new InvalidOperationException("数据已被其他用户修改,请刷新后重试");
if (entity.IsLock) throw new InvalidOperationException("规则已锁定,不能修改");
return entity;
}
private async Task EnsureUniqueCode(string code, Guid id)
{
if (await _db.WmsTransportRules.AnyAsync(x => x.Code == code.Trim() && x.Id != id))
throw new InvalidOperationException("规则编码已存在");
await _rules.SoftDeleteAsync(id, version);
await _uow.SaveChangesAsync();
}
private static void StampCreate(EntityBase entity, string actor)
+10 -9
View File
@@ -27,12 +27,13 @@ public sealed class WmsTransportTaskService
public async Task<List<WmsTransportTask>> ListAsync(string? status = null, string? triggerType = null)
{
var query = _db.WmsTransportTasks.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(x => x.Status == status);
if (!string.IsNullOrWhiteSpace(status) &&
Enum.TryParse<WmsTransportTaskStatus>(status.Trim(), true, out var st) &&
WmsTransportTaskStatuses.All.Contains(st))
query = query.Where(x => x.Status == st);
if (!string.IsNullOrWhiteSpace(triggerType))
query = query.Where(x => x.BusinessType == triggerType);
var rows = await query.ToListAsync();
return rows.OrderByDescending(x => x.CreatedAt).Take(500).ToList();
return await query.OrderByDescending(x => x.CreatedAt).Take(500).ToListAsync();
}
public Task<TransportCandidatePreview> PreviewCandidatesAsync(WmsTransportRequest request) =>
@@ -80,7 +81,7 @@ public sealed class WmsTransportTaskService
if (status == WmsTransportTaskStatuses.Reserved)
await CreateReservationAsync(task, options, actor);
await AppendHistoryAsync(task.Id, "", task.Status, actor, request.Reason, "", task.SnapshotJson);
await AppendHistoryAsync(task.Id, "", task.Status.ToString(), actor, request.Reason, "", task.SnapshotJson);
await tx.CommitAsync();
return task;
}
@@ -147,8 +148,8 @@ public sealed class WmsTransportTaskService
try
{
await _wms.BindOrTransferLocation(new ContainerLocationRequest(
null, null, task.ContainerId, ContainerLocationTypes.Storage,
task.TargetStorageId.ToString("D"), ContainerLocationStatuses.Active,
null, null, task.ContainerId, ContainerLocationTypes.Storage.ToString(),
task.TargetStorageId.ToString("D"), ContainerLocationStatuses.Active.ToString(),
DateTimeOffset.UtcNow, "TransportTask", reason, false, "", "{}"), actor);
await ReleaseReservationsAsync(task.Id);
@@ -245,13 +246,13 @@ public sealed class WmsTransportTaskService
await _db.SaveChangesAsync();
}
private async Task ChangeStatusAsync(WmsTransportTask task, string toStatus, string actor, string reason)
private async Task ChangeStatusAsync(WmsTransportTask task, WmsTransportTaskStatus toStatus, string actor, string reason)
{
var from = task.Status;
task.Status = toStatus;
task.UpdatedBy = actor;
await _db.SaveChangesAsync();
await AppendHistoryAsync(task.Id, from, toStatus, actor, reason, task.ErrorMessage, task.SnapshotJson);
await AppendHistoryAsync(task.Id, from.ToString(), toStatus.ToString(), actor, reason, task.ErrorMessage, task.SnapshotJson);
}
private async Task AppendHistoryAsync(Guid taskId, string from, string to, string actor, string reason, string error, string snapshot)