持久层重构为独立 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
+20 -3
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using MiGu.DB.Abstractions.Exceptions;
using MiGu.Server.Wms;
namespace MiGu.Server.Controllers;
@@ -276,15 +277,31 @@ public sealed class WmsExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is not InvalidOperationException ex) return;
context.Result = new BadRequestObjectResult(new { message = ex.Message });
switch (context.Exception)
{
case ConcurrencyConflictException ex:
context.Result = new ObjectResult(new { message = ex.Message }) { StatusCode = StatusCodes.Status409Conflict };
break;
case EntityLockedException ex:
context.Result = new ObjectResult(new { message = ex.Message }) { StatusCode = StatusCodes.Status409Conflict };
break;
case EntityNotFoundException ex:
context.Result = new NotFoundObjectResult(new { message = ex.Message });
break;
case InvalidOperationException ex:
context.Result = new BadRequestObjectResult(new { message = ex.Message });
break;
default:
return;
}
context.ExceptionHandled = true;
}
}
public sealed class TransportRequestQuery
{
public string TriggerType { get; set; } = WmsTransportTriggerTypes.MaterialCall;
public string TriggerType { get; set; } = nameof(WmsTransportTriggerType.MaterialCall);
public Guid? RuleId { get; set; }
public string? RequestSiteId { get; set; }
public Guid? MaterialId { get; set; }
@@ -1,10 +0,0 @@
namespace MiGu.Server.Dashboard;
/// <summary>用户 Dashboard 快捷入口配置(按 user + scope 一行)。</summary>
public sealed class UserDashboardShortcut
{
public string UserId { get; set; } = "";
public string Scope { get; set; } = "";
public string KeysJson { get; set; } = "[]";
public DateTimeOffset UpdatedAt { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
global using PlatformDbContext = MiGu.DB.Kernel.Context.MiGuDbContext;
global using EntityBase = MiGu.DB.Kernel.Entities.AggregateRoot;
global using SimpleField = MiGu.DB.Domains.SimpleFields.SimpleField;
global using UserDashboardShortcut = MiGu.DB.Domains.Dashboard.UserDashboardShortcut;
global using Warehouse = MiGu.DB.Domains.Wms.Warehouse;
global using WarehouseArea = MiGu.DB.Domains.Wms.WarehouseArea;
global using Storage = MiGu.DB.Domains.Wms.Storage;
global using Container = MiGu.DB.Domains.Wms.Container;
global using MaterialType = MiGu.DB.Domains.Wms.MaterialType;
global using Material = MiGu.DB.Domains.Wms.Material;
global using ContainerLocation = MiGu.DB.Domains.Wms.ContainerLocation;
global using ContainerMaterial = MiGu.DB.Domains.Wms.ContainerMaterial;
global using StockEvent = MiGu.DB.Domains.Wms.StockEvent;
global using ContainerLocationHistory = MiGu.DB.Domains.Wms.ContainerLocationHistory;
global using ContainerMaterialHistory = MiGu.DB.Domains.Wms.ContainerMaterialHistory;
global using WmsTransportRule = MiGu.DB.Domains.Transport.WmsTransportRule;
global using WmsTransportTask = MiGu.DB.Domains.Transport.WmsTransportTask;
global using WmsTransportReservation = MiGu.DB.Domains.Transport.WmsTransportReservation;
global using WmsTransportTaskHistory = MiGu.DB.Domains.Transport.WmsTransportTaskHistory;
+8 -5
View File
@@ -16,11 +16,14 @@
<PackageReference Include="Yarp.ReverseProxy" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.10" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MiGu.DB\MiGu.DB.csproj" />
</ItemGroup>
<ItemGroup>
-17
View File
@@ -1,17 +0,0 @@
namespace MiGu.Server.Persistence;
public abstract class EntityBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public string DeletedBy { get; set; } = "";
public long Version { get; set; }
public bool IsLock { get; set; }
public string CreatedBy { get; set; } = "";
public string UpdatedBy { get; set; } = "";
public string Remark { get; set; } = "";
public string Extend { get; set; } = "{}";
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Http;
using MiGu.DB.Abstractions.Runtime;
using MiGu.DB.Kernel.Conventions;
namespace MiGu.Server.Persistence;
/// <summary>从 JWT Claims 解析当前操作者,写入 IActorContextAccessor。</summary>
public sealed class HttpActorContextMiddleware
{
private readonly RequestDelegate _next;
public HttpActorContextMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext http, IActorContextAccessor actors)
{
var name = http.User?.Identity?.Name
?? http.User?.FindFirst("sub")?.Value
?? http.User?.FindFirst("unique_name")?.Value
?? "system";
actors.Current = new HttpActorContext(name);
try
{
await _next(http);
}
finally
{
actors.Current = SystemActorContext.Instance;
}
}
private sealed class HttpActorContext : IActorContext
{
public HttpActorContext(string name) => Name = name;
public string Name { get; }
}
}
@@ -1,253 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MiGu.Server.Dashboard;
using MiGu.Server.Wms;
using MiGu.Server.SimpleFields;
namespace MiGu.Server.Persistence;
public sealed class PlatformDbContext : DbContext
{
public PlatformDbContext(DbContextOptions<PlatformDbContext> options) : base(options) { }
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<WarehouseArea> WarehouseAreas => Set<WarehouseArea>();
public DbSet<Storage> Storages => Set<Storage>();
public DbSet<Container> Containers => Set<Container>();
public DbSet<MaterialType> MaterialTypes => Set<MaterialType>();
public DbSet<Material> Materials => Set<Material>();
public DbSet<ContainerLocation> ContainerLocations => Set<ContainerLocation>();
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
public DbSet<StockEvent> StockEvents => Set<StockEvent>();
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
public DbSet<WmsTransportRule> WmsTransportRules => Set<WmsTransportRule>();
public DbSet<WmsTransportTask> WmsTransportTasks => Set<WmsTransportTask>();
public DbSet<WmsTransportReservation> WmsTransportReservations => Set<WmsTransportReservation>();
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var guid = new ValueConverter<Guid, string>(
v => v.ToString("D"),
v => Guid.Parse(v));
var nullableGuid = new ValueConverter<Guid?, string?>(
v => v.HasValue ? v.Value.ToString("D") : null,
v => string.IsNullOrWhiteSpace(v) ? null : Guid.Parse(v));
var dateTimeOffset = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("O"),
v => DateTimeOffset.Parse(v));
var nullableDateTimeOffset = new ValueConverter<DateTimeOffset?, string?>(
v => v.HasValue ? v.Value.UtcDateTime.ToString("O") : null,
v => string.IsNullOrWhiteSpace(v) ? null : DateTimeOffset.Parse(v));
foreach (var entity in modelBuilder.Model.GetEntityTypes())
{
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(guid).HasMaxLength(36);
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid?)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableGuid).HasMaxLength(36);
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(dateTimeOffset).HasMaxLength(40);
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset?)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableDateTimeOffset).HasMaxLength(40);
}
ConfigureEntityBase<Warehouse>(modelBuilder, "wms_warehouses");
ConfigureEntityBase<WarehouseArea>(modelBuilder, "wms_areas");
ConfigureEntityBase<Storage>(modelBuilder, "wms_storages");
ConfigureEntityBase<Container>(modelBuilder, "wms_containers");
ConfigureEntityBase<MaterialType>(modelBuilder, "wms_material_types");
ConfigureEntityBase<Material>(modelBuilder, "wms_materials");
ConfigureEntityBase<ContainerLocation>(modelBuilder, "wms_container_locations");
ConfigureEntityBase<ContainerMaterial>(modelBuilder, "wms_container_materials");
ConfigureEntityBase<WmsTransportRule>(modelBuilder, "wms_transport_rules");
ConfigureEntityBase<WmsTransportTask>(modelBuilder, "wms_transport_tasks");
ConfigureEntityBase<WmsTransportReservation>(modelBuilder, "wms_transport_reservations");
ConfigureHistory<ContainerLocationHistory>(modelBuilder, "wms_container_location_history");
ConfigureHistory<ContainerMaterialHistory>(modelBuilder, "wms_container_material_history");
ConfigureStockEvent(modelBuilder);
ConfigureTransportTaskHistory(modelBuilder);
modelBuilder.Entity<Warehouse>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<WarehouseArea>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<WarehouseArea>().HasIndex(x => x.WarehouseId);
modelBuilder.Entity<Storage>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Storage>().HasIndex(x => x.AreaId);
modelBuilder.Entity<Storage>().HasIndex(x => x.Barcode);
modelBuilder.Entity<Container>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<MaterialType>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Material>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Material>().HasIndex(x => x.Barcode);
modelBuilder.Entity<Material>().HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt });
modelBuilder.Entity<Material>().HasIndex(x => x.TypeCode);
modelBuilder.Entity<ContainerLocation>().HasIndex(x => x.ContainerId).IsUnique();
modelBuilder.Entity<ContainerLocation>().HasIndex(x => new { x.LocationType, x.LocationId });
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.MaterialId).IsUnique();
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.ContainerId);
modelBuilder.Entity<WmsTransportRule>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<WmsTransportRule>().HasIndex(x => new { x.TriggerType, x.Enabled, x.Priority });
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.Status);
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.ContainerId);
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.TargetStorageId);
modelBuilder.Entity<WmsTransportReservation>().HasIndex(x => new { x.ContainerId, x.Status });
modelBuilder.Entity<WmsTransportReservation>().HasIndex(x => new { x.TargetStorageId, x.Status });
modelBuilder.Entity<WmsTransportTask>().Property(x => x.Quantity).HasPrecision(18, 4);
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
ConfigureSimpleField(modelBuilder);
ConfigureUserDashboardShortcut(modelBuilder);
}
private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<UserDashboardShortcut>();
e.ToTable("user_dashboard_shortcuts");
e.HasKey(x => new { x.UserId, x.Scope });
e.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64);
e.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32);
e.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text");
var dateTime = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("O"),
v => DateTimeOffset.Parse(v));
e.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40);
}
private static void ConfigureSimpleField(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<SimpleField>();
e.ToTable("simple_fields");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.CarType).HasColumnName("car_type").HasMaxLength(64);
e.Property(x => x.FieldType).HasColumnName("field_type").HasMaxLength(64);
e.Property(x => x.Key).HasColumnName("key").HasMaxLength(128);
e.Property(x => x.Value).HasColumnName("value");
e.Property(x => x.DataType).HasColumnName("data_type").HasMaxLength(128);
e.Property(x => x.Chinese).HasColumnName("chinese").HasMaxLength(256).IsRequired(false);
e.Property(x => x.English).HasColumnName("english").HasMaxLength(256).IsRequired(false);
e.Property(x => x.Other).HasColumnName("other").HasMaxLength(512);
e.Property(x => x.IsDefault).HasColumnName("is_default");
var dateTime = new ValueConverter<DateTimeOffset, string>(
v => SimpleFieldDateTime.ToStorage(v),
v => SimpleFieldDateTime.FromStorage(v));
e.Property(x => x.CreateTime).HasColumnName("create_time").HasConversion(dateTime).HasMaxLength(19);
e.Property(x => x.UpdateTime).HasColumnName("update_time").HasConversion(dateTime).HasMaxLength(19);
e.HasIndex(x => new { x.CarType, x.FieldType, x.Key }).IsUnique();
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
StampEntities();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
StampEntities();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void StampEntities()
{
var now = DateTimeOffset.UtcNow;
foreach (var e in ChangeTracker.Entries<EntityBase>())
{
if (e.State == EntityState.Added)
{
if (e.Entity.Id == Guid.Empty) e.Entity.Id = Guid.NewGuid();
e.Entity.CreatedAt = now;
e.Entity.UpdatedAt = now;
e.Entity.Version = Math.Max(1, e.Entity.Version);
if (string.IsNullOrWhiteSpace(e.Entity.Extend)) e.Entity.Extend = "{}";
}
else if (e.State == EntityState.Modified)
{
e.Entity.UpdatedAt = now;
e.Entity.Version += 1;
if (string.IsNullOrWhiteSpace(e.Entity.Extend)) e.Entity.Extend = "{}";
}
}
}
private static void ConfigureEntityBase<T>(ModelBuilder modelBuilder, string table) where T : EntityBase
{
var e = modelBuilder.Entity<T>();
e.ToTable(table);
e.HasKey(x => x.Id);
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.Property(x => x.UpdatedBy).HasMaxLength(128);
e.Property(x => x.DeletedBy).HasMaxLength(128);
e.Property(x => x.Remark).HasMaxLength(1000);
e.Property(x => x.Extend).HasColumnType("text");
e.HasQueryFilter(x => !x.IsDeleted);
}
private static void ConfigureHistory<T>(ModelBuilder modelBuilder, string table) where T : WarehouseHistoryBase
{
var e = modelBuilder.Entity<T>();
e.ToTable(table);
e.HasKey(x => x.Id);
e.Property(x => x.EventType).HasMaxLength(64);
e.Property(x => x.BeforeJson).HasColumnType("text");
e.Property(x => x.AfterJson).HasColumnType("text");
e.Property(x => x.Operator).HasMaxLength(128);
e.Property(x => x.Source).HasMaxLength(32);
e.Property(x => x.Reason).HasMaxLength(500);
e.Property(x => x.Remark).HasMaxLength(1000);
e.Property(x => x.Extend).HasColumnType("text");
e.HasIndex(x => x.ContainerId);
e.HasIndex(x => x.OperatedAt);
e.HasIndex(x => x.EventType);
}
private static void ConfigureStockEvent(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<StockEvent>();
e.ToTable("wms_stock_events");
e.HasKey(x => x.Id);
e.Property(x => x.EventType).HasMaxLength(32);
e.Property(x => x.MaterialCode).HasMaxLength(64);
e.Property(x => x.MaterialName).HasMaxLength(128);
e.Property(x => x.MaterialBarcode).HasMaxLength(128);
e.Property(x => x.MaterialTypeCode).HasMaxLength(64);
e.Property(x => x.ContainerCode).HasMaxLength(64);
e.Property(x => x.ContainerName).HasMaxLength(128);
e.Property(x => x.StorageCode).HasMaxLength(64);
e.Property(x => x.StorageName).HasMaxLength(128);
e.Property(x => x.AreaCode).HasMaxLength(64);
e.Property(x => x.FromStorageCode).HasMaxLength(64);
e.Property(x => x.FromStorageName).HasMaxLength(128);
e.Property(x => x.ToStorageCode).HasMaxLength(64);
e.Property(x => x.ToStorageName).HasMaxLength(128);
e.Property(x => x.RefType).HasMaxLength(64);
e.Property(x => x.RefCode).HasMaxLength(64);
e.Property(x => x.Operator).HasMaxLength(128);
e.Property(x => x.Reason).HasMaxLength(500);
e.HasIndex(x => x.OperatedAt);
e.HasIndex(x => x.EventType);
e.HasIndex(x => x.MaterialId);
e.HasIndex(x => x.ContainerId);
}
private static void ConfigureTransportTaskHistory(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<WmsTransportTaskHistory>();
e.ToTable("wms_transport_task_history");
e.HasKey(x => x.Id);
e.Property(x => x.FromStatus).HasMaxLength(32);
e.Property(x => x.ToStatus).HasMaxLength(32);
e.Property(x => x.Operator).HasMaxLength(128);
e.Property(x => x.Reason).HasMaxLength(500);
e.Property(x => x.ErrorMessage).HasMaxLength(1000);
e.Property(x => x.SnapshotJson).HasColumnType("text");
e.HasIndex(x => x.TaskId);
e.HasIndex(x => x.OperatedAt);
}
}
+16 -452
View File
@@ -1,464 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Data.Sqlite;
using MiGu.Server.Wms;
using MiGu.Server.SimpleFields;
using MiGu.DB.Kernel.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace MiGu.Server.Persistence;
/// <summary>兼容入口:转调 MiGu.DB Hosting,并注册本进程业务服务。</summary>
public static class PlatformPersistence
{
public static IServiceCollection AddPlatformPersistence(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<PlatformDbContext>((sp, options) =>
{
var env = sp.GetRequiredService<IWebHostEnvironment>();
var provider = configuration["Database:Provider"] ?? "sqlite";
var connection = ResolveConnectionString(configuration, env, provider);
services.AddMiGuDb(configuration);
switch (provider.Trim().ToLowerInvariant())
{
case "sqlite":
options.UseSqlite(connection);
break;
case "mysql":
options.UseMySql(connection, ServerVersion.AutoDetect(connection));
break;
case "postgres":
case "postgresql":
case "npgsql":
options.UseNpgsql(connection);
break;
case "sqlserver":
case "mssql":
options.UseSqlServer(connection);
break;
default:
throw new InvalidOperationException($"未知数据库 Provider: {provider}");
}
});
services.AddScoped<WmsReferenceValidator>();
services.AddScoped<WmsService>();
services.AddScoped<WmsTransportRuleService>();
services.AddScoped<WmsTransportPlanner>();
services.AddScoped<WmsTransportTaskService>();
services.AddScoped<IWmsDispatchAdapter, NoopWmsDispatchAdapter>();
services.AddScoped<SimpleFieldService>();
services.AddScoped<MiGu.Server.Dashboard.DashboardShortcutService>();
services.AddScoped<Wms.WmsReferenceValidator>();
services.AddScoped<Wms.WmsService>();
services.AddScoped<Wms.WmsTransportRuleService>();
services.AddScoped<Wms.WmsTransportPlanner>();
services.AddScoped<Wms.WmsTransportTaskService>();
services.AddScoped<Wms.IWmsDispatchAdapter, Wms.NoopWmsDispatchAdapter>();
services.AddScoped<SimpleFields.SimpleFieldService>();
services.AddScoped<Dashboard.DashboardShortcutService>();
return services;
}
public static async Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
await db.Database.EnsureCreatedAsync();
// EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。
await EnsureSimpleFieldsTableAsync(db);
await EnsureUserDashboardShortcutsTableAsync(db);
await EnsureWmsTransportSchemaAsync(db);
await EnsureWmsStructureSchemaAsync(db);
await MigrateWmsLegacyAsync(scope.ServiceProvider);
}
private static async Task MigrateWmsLegacyAsync(IServiceProvider sp)
{
try
{
var wms = sp.GetRequiredService<WmsService>();
await wms.MigrateLegacyAsync("system");
}
catch
{
// 首次建库或缺列时忽略,后续请求可再触发
}
}
/// <summary>补建仓库/物料类型/库存事件及结构扩展列(幂等,SQLite)。</summary>
private static async Task EnsureWmsStructureSchemaAsync(PlatformDbContext db)
{
if (!db.Database.IsSqlite()) return;
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_warehouses (
Id TEXT NOT NULL CONSTRAINT PK_wms_warehouses PRIMARY KEY,
Code TEXT NOT NULL,
Name TEXT NOT NULL,
Type TEXT NOT NULL,
Enabled INTEGER NOT NULL,
SortOrder INTEGER NOT NULL,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT NOT NULL DEFAULT '',
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL DEFAULT '',
UpdatedBy TEXT NOT NULL DEFAULT '',
Remark TEXT NOT NULL DEFAULT '',
Extend TEXT NOT NULL DEFAULT '{{}}'
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_warehouses_Code ON wms_warehouses (Code);");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_material_types (
Id TEXT NOT NULL CONSTRAINT PK_wms_material_types PRIMARY KEY,
Code TEXT NOT NULL,
Name TEXT NOT NULL,
Spec TEXT NOT NULL DEFAULT '',
Unit TEXT NOT NULL DEFAULT 'pcs',
Category TEXT NOT NULL DEFAULT '',
BarcodePrefix TEXT NOT NULL DEFAULT '',
Enabled INTEGER NOT NULL,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT NOT NULL DEFAULT '',
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL DEFAULT '',
UpdatedBy TEXT NOT NULL DEFAULT '',
Remark TEXT NOT NULL DEFAULT '',
Extend TEXT NOT NULL DEFAULT '{{}}'
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_material_types_Code ON wms_material_types (Code);");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_stock_events (
Id TEXT NOT NULL CONSTRAINT PK_wms_stock_events PRIMARY KEY,
EventType TEXT NOT NULL,
MaterialId TEXT,
MaterialCode TEXT NOT NULL DEFAULT '',
MaterialName TEXT NOT NULL DEFAULT '',
MaterialBarcode TEXT NOT NULL DEFAULT '',
MaterialTypeCode TEXT NOT NULL DEFAULT '',
ContainerId TEXT,
ContainerCode TEXT NOT NULL DEFAULT '',
ContainerName TEXT NOT NULL DEFAULT '',
StorageId TEXT,
StorageCode TEXT NOT NULL DEFAULT '',
StorageName TEXT NOT NULL DEFAULT '',
AreaCode TEXT NOT NULL DEFAULT '',
FromStorageId TEXT,
FromStorageCode TEXT NOT NULL DEFAULT '',
FromStorageName TEXT NOT NULL DEFAULT '',
ToStorageId TEXT,
ToStorageCode TEXT NOT NULL DEFAULT '',
ToStorageName TEXT NOT NULL DEFAULT '',
RefType TEXT NOT NULL DEFAULT 'Manual',
RefId TEXT,
RefCode TEXT NOT NULL DEFAULT '',
Operator TEXT NOT NULL DEFAULT '',
OperatedAt TEXT NOT NULL,
Reason TEXT NOT NULL DEFAULT ''
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_wms_stock_events_OperatedAt ON wms_stock_events (OperatedAt);");
await EnsureSqliteColumnAsync(db, "wms_areas", "WarehouseId", "TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000'");
await EnsureSqliteColumnAsync(db, "wms_areas", "LayoutMode", "TEXT NOT NULL DEFAULT 'Flat'");
await EnsureSqliteColumnAsync(db, "wms_areas", "State", "TEXT NOT NULL DEFAULT 'Default'");
await EnsureSqliteColumnAsync(db, "wms_storages", "LocationKind", "TEXT NOT NULL DEFAULT 'Station'");
await EnsureSqliteColumnAsync(db, "wms_storages", "ColumnNo", "INTEGER NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_storages", "LevelNo", "INTEGER NOT NULL DEFAULT 1");
await EnsureSqliteColumnAsync(db, "wms_storages", "DepthNo", "INTEGER NOT NULL DEFAULT 1");
await EnsureSqliteColumnAsync(db, "wms_storages", "SiteCode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_storages", "Barcode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_containers", "AreaId", "TEXT");
await EnsureSqliteColumnAsync(db, "wms_containers", "Barcode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_containers", "Length", "REAL NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_containers", "Width", "REAL NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_containers", "Height", "REAL NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_materials", "TypeCode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_materials", "Barcode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_materials", "LifecycleStatus", "TEXT NOT NULL DEFAULT 'Active'");
await EnsureSqliteColumnAsync(db, "wms_materials", "UnboundAt", "TEXT");
await EnsureSqliteColumnAsync(db, "wms_container_materials", "BoundAt", "TEXT NOT NULL DEFAULT ''");
}
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db)
{
if (db.Database.IsSqlite())
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS simple_fields (
id TEXT NOT NULL CONSTRAINT PK_simple_fields PRIMARY KEY,
car_type TEXT NOT NULL DEFAULT '',
field_type TEXT NOT NULL,
"key" TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
data_type TEXT NOT NULL DEFAULT '',
chinese TEXT,
english TEXT,
other TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL,
create_time TEXT NOT NULL,
update_time TEXT NOT NULL
);
""");
// 须先删旧索引 (field_type, other, key):把 other 清空为「其他语言」后会与旧唯一约束冲突。
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_simple_fields_field_type_other_key;");
await db.Database.ExecuteSqlRawAsync("""
UPDATE simple_fields SET car_type = other
WHERE (car_type IS NULL OR car_type = '') AND other <> '';
""");
await db.Database.ExecuteSqlRawAsync("""
UPDATE simple_fields SET other = ''
WHERE other <> '' AND other = car_type;
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_simple_fields_car_type_field_type_key
ON simple_fields (car_type, field_type, "key");
""");
return;
}
// 非 SQLite:表不存在时尝试按当前模型创建(已有库不会走 EnsureCreated)。
if (!await TableExistsAsync(db, "simple_fields"))
{
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <summary>为已存在的数据库补建 WMS 搬运规则/任务相关表及库位扩展列(幂等)。</summary>
private static async Task EnsureWmsTransportSchemaAsync(PlatformDbContext db)
{
if (!db.Database.IsSqlite()) return;
await EnsureSqliteColumnAsync(db, "wms_storages", "Status", "TEXT NOT NULL DEFAULT 'Available'");
await EnsureSqliteColumnAsync(db, "wms_storages", "Usage", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_storages", "Priority", "INTEGER NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_storages", "ZoneCode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_storages", "AllowInbound", "INTEGER NOT NULL DEFAULT 1");
await EnsureSqliteColumnAsync(db, "wms_storages", "AllowOutbound", "INTEGER NOT NULL DEFAULT 1");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_rules (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_rules PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT,
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL,
UpdatedBy TEXT NOT NULL,
Remark TEXT NOT NULL,
Extend TEXT NOT NULL,
Code TEXT NOT NULL,
Name TEXT NOT NULL,
TriggerType TEXT NOT NULL,
Enabled INTEGER NOT NULL,
Priority INTEGER NOT NULL,
SourceSelectorJson TEXT NOT NULL,
TargetSelectorJson TEXT NOT NULL,
TaskOptionsJson TEXT NOT NULL
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_transport_rules_Code ON wms_transport_rules (Code);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_tasks (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_tasks PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT,
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL,
UpdatedBy TEXT NOT NULL,
Remark TEXT NOT NULL,
Extend TEXT NOT NULL,
BusinessType TEXT NOT NULL,
RuleId TEXT,
SourceStorageId TEXT NOT NULL,
TargetStorageId TEXT NOT NULL,
ContainerId TEXT NOT NULL,
MaterialId TEXT,
Quantity REAL,
Status TEXT NOT NULL,
DispatchMissionId TEXT NOT NULL DEFAULT '',
DeliveryId TEXT NOT NULL DEFAULT '',
DispatchStatus TEXT NOT NULL DEFAULT '',
Reason TEXT NOT NULL DEFAULT '',
SnapshotJson TEXT NOT NULL,
ErrorMessage TEXT NOT NULL DEFAULT '',
TaskPriority INTEGER NOT NULL DEFAULT 0
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_reservations (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_reservations PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT,
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL,
UpdatedBy TEXT NOT NULL,
Remark TEXT NOT NULL,
Extend TEXT NOT NULL,
TaskId TEXT NOT NULL,
ContainerId TEXT NOT NULL,
SourceStorageId TEXT NOT NULL,
TargetStorageId TEXT NOT NULL,
Status TEXT NOT NULL,
ExpiresAt TEXT
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_task_history (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_task_history PRIMARY KEY,
TaskId TEXT NOT NULL,
FromStatus TEXT NOT NULL,
ToStatus TEXT NOT NULL,
Operator TEXT NOT NULL,
OperatedAt TEXT NOT NULL,
Reason TEXT NOT NULL,
ErrorMessage TEXT NOT NULL,
SnapshotJson TEXT NOT NULL
);
""");
}
private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({table});";
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var name = reader.GetString(1);
if (string.Equals(name, column, StringComparison.OrdinalIgnoreCase))
return;
}
await db.Database.ExecuteSqlRawAsync($"ALTER TABLE {table} ADD COLUMN {column} {definition};");
}
/// <summary>为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。</summary>
private static async Task EnsureUserDashboardShortcutsTableAsync(PlatformDbContext db)
{
if (db.Database.IsSqlite())
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS user_dashboard_shortcuts (
user_id TEXT NOT NULL,
scope TEXT NOT NULL,
keys_json TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL,
CONSTRAINT PK_user_dashboard_shortcuts PRIMARY KEY (user_id, scope)
);
""");
return;
}
if (!await TableExistsAsync(db, "user_dashboard_shortcuts"))
{
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <summary>
/// 检查表是否存在
/// </summary>
/// <param name="db">数据库上下文</param>
/// <param name="table">表名</param>
/// <returns>表是否存在</returns>
private static async Task<bool> TableExistsAsync(PlatformDbContext db, string table)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
await conn.OpenAsync();
try
{
await using var cmd = conn.CreateCommand();
if (db.Database.IsSqlServer())
{
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @t";
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
}
else if (db.Database.IsNpgsql())
{
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_name = @t";
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
}
else if (db.Database.IsMySql())
{
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = @t";
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
}
else
{
return false;
}
var result = await cmd.ExecuteScalarAsync();
return result != null;
}
finally
{
if (conn.State == System.Data.ConnectionState.Open)
await conn.CloseAsync();
}
}
private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider)
{
var key = provider.Trim().ToLowerInvariant() switch
{
"postgres" or "postgresql" or "npgsql" => "PostgreSQL",
"mssql" => "SqlServer",
_ => provider
};
var configured = configuration.GetConnectionString(key) ?? configuration.GetConnectionString("Platform");
if (!string.IsNullOrWhiteSpace(configured))
{
return IsSqlite(provider) ? NormalizeSqliteConnection(configured, env) : configured;
}
var dataDir = Path.Combine(env.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
return $"Data Source={Path.Combine(dataDir, "platform.db")}";
}
private static bool IsSqlite(string provider) =>
string.Equals(provider.Trim(), "sqlite", StringComparison.OrdinalIgnoreCase);
private static string NormalizeSqliteConnection(string connection, IWebHostEnvironment env)
{
var builder = new SqliteConnectionStringBuilder(connection);
if (string.IsNullOrWhiteSpace(builder.DataSource)) return connection;
if (builder.DataSource is ":memory:") return connection;
if (!Path.IsPathRooted(builder.DataSource))
{
builder.DataSource = Path.Combine(env.ContentRootPath, builder.DataSource);
}
var dir = Path.GetDirectoryName(builder.DataSource);
if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir);
return builder.ToString();
}
/// <summary>启动建库/迁移入口(内部即 MigrateMiGuDbAsyncMigrate + 基线 + DataMigrator)。</summary>
public static Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
=> services.MigrateMiGuDbAsync();
}
+1
View File
@@ -303,6 +303,7 @@ app.UseStaticFiles();
// 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。
app.UseAuthentication();
app.UseMiddleware<HttpActorContextMiddleware>();
app.UseAuthorization();
app.MapControllers();
+85 -1
View File
@@ -97,6 +97,88 @@ $env:SimpleLite__Enabled = "true"
> 如果只想跑 MiGu.Server 单进程调试(不拉 SimpleLite),把 `appsettings.json:SimpleLite.Enabled` 改为 `false` 即可。
## 数据库启动流程
持久层在独立工程 **[MiGu.DB](../MiGu.DB/README.md)**Server 通过 `PlatformPersistence` 接入。
### 启动顺序(`Program.cs`
```text
AddPlatformPersistence(configuration) # 注册 AddMiGuDb + Wms/SimpleFields 等业务服务
builder.Build()
EnsurePlatformDatabaseAsync() # → MiGu.DB.MigrateMiGuDbAsync()
│ # Schema 初始化 + 可选 IDataMigrator
… 其余中间件 …
UseMiddleware<HttpActorContextMiddleware> # 请求级写入 IActorContext(审计戳)
```
### 配置(`appsettings*.json`
```json
"Database": {
"Provider": "sqlite",
"SchemaMode": "Migrate",
"ApplyDataMigratorsOnStartup": true
},
"ConnectionStrings": {
"Platform": "Data Source=data/platform.db"
}
```
| 配置项 | 含义 | 取值 |
|--------|------|------|
| `Provider` | 数据库种类 | `sqlite`(默认)/ `mysql` / `npgsql` / `sqlserver` |
| `SchemaMode` | 启动时如何初始化 Schema | 见下表 |
| `ApplyDataMigratorsOnStartup` | Schema 完成后是否跑数据修补 | `true`(默认)/ `false` |
当前仓库约定:
- `appsettings.json``"SchemaMode": "Migrate"`(发版/默认)
- `appsettings.Development.json``"SchemaMode": "EnsureCreated"`(本地开发)
也可用环境变量覆盖,例如:`$env:Database__SchemaMode = "Migrate"`
### SchemaMode 行为
| 值 | 行为 | 适用 |
|----|------|------|
| **`Migrate`** | 旧库无 `__EFMigrationsHistory` 时先写 Initial 基线 → `MigrateAsync` →(可选)DataMigrator | 测试 / 预发 / 生产 |
| **`EnsureCreated`** | 仅 `EnsureCreated`(按**当前模型**建空库),**不**跑 Migrations →(可选)DataMigrator | 开发期改实体、暂不生成迁移 |
流程图:
```text
SchemaMode = EnsureCreated
→ EnsureCreatedAsync(库已存在则不改结构)
→ IDataMigratorApplyDataMigratorsOnStartup=true 时)
SchemaMode = Migrate
→ 程序集无 Migration → EnsureCreated 兜底
→ 否则:基线(如需)→ MigrateAsync
→ IDataMigrator(可选)
```
**开发注意(EnsureCreated):**
- 改实体后结构不会自动升级;请删除 `MiGu.Server/data/platform.db*` 再启动。
- 不会写入迁移历史;以后改回 `Migrate` 时,对已有库会走基线再应用 Migrations。
**发版注意:**
- 现场务必使用 `Migrate`,并随包带上 `MiGu.DB/Migrations/Sqlite/`
- 开发期若长期 `EnsureCreated` 改模型,发版前必须对**当前模型**执行 `dotnet ef migrations add`,再切回 `Migrate` 验证;否则空库只会落到旧 Migration,结构落后于代码。
- 从 EnsureCreated 库切到 `Migrate`:若无 History,启动会把**全部** pending Migration 写入基线(假定库已对齐模型 tip)。结构不对齐时应删库重建或手工处理。
### 相关文档
- 持久层总览与 SchemaMode 摘要:[MiGu.DB/README.md](../MiGu.DB/README.md)
- 如何生成/管理 Migration[MiGu.DB/MIGRATIONS.md](../MiGu.DB/MIGRATIONS.md)
## 安全须知(生产部署必读)
> **重要:以下默认值仅供本地开发,切勿原样用于生产环境。**
@@ -136,7 +218,7 @@ $env:SimpleLite__Enabled = "true"
- 健康检查:`/api/health`
- Swagger:开发环境下 `/swagger`
> 不在本轮范围:真实 SimpleLite WebAPI、SystemMission 拉起、真实 JWT/RBAC、SQLite/EF Core 持久层
> 能力补充:平台库由 **MiGu.DB**(EF Core)承载,启动流程见「[数据库启动流程](#数据库启动流程)」
## 目录结构
@@ -242,3 +324,5 @@ YARP 路由配置见 `appsettings.json`
- [ARCHITECTURE.md](ARCHITECTURE.md) — v1.6 总体架构(§4 进程拓扑、§6.3 YARP 配置、§9 配置中心、§10 交互序列、§17 视觉规范)
- [(见 Simple 仓库)frontends/README.md]((见 Simple 仓库)frontends/README.md) — 前端启动与联调说明(含「迷毂」品牌与紫色主题约定)
- [MiGu.DB/README.md](../MiGu.DB/README.md) — 持久层框架;[MIGRATIONS.md](../MiGu.DB/MIGRATIONS.md) — 生成 Migration 手册
- 本文「[数据库启动流程](#数据库启动流程)」— SchemaMode / EnsureCreated / Migrate
@@ -1,21 +0,0 @@
using System.Globalization;
namespace MiGu.Server.SimpleFields;
public static class SimpleFieldDateTime
{
public const string StorageFormat = "yyyy-MM-dd HH:mm:ss";
public static DateTimeOffset Now => DateTimeOffset.Now;
public static string ToStorage(DateTimeOffset value) =>
value.LocalDateTime.ToString(StorageFormat, CultureInfo.InvariantCulture);
public static DateTimeOffset FromStorage(string value)
{
if (DateTime.TryParseExact(value, StorageFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var local))
return new DateTimeOffset(local);
return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
}
}
@@ -1,72 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace MiGu.Server.SimpleFields;
public sealed class SimpleField
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>车型唯一标识:assemblyName.shortName,如 StandardScene.QrLidar.Forklift。</summary>
[MaxLength(64)]
public string CarType { get; set; } = "";
/// <summary>
/// 字段类型唯一标识:assemblyName.shortName,如 StandardScene.QrLidar.Forklift。
/// </summary>
[MaxLength(64)]
public string FieldType { get; set; } = "";
/// <summary>
/// 字段唯一标识:key,如 Forklift.PositionX。
/// </summary>
[MaxLength(128)]
public string Key { get; set; } = "";
/// <summary>
/// 字段值,如 10.0。
/// </summary>
public string Value { get; set; } = "";
/// <summary>
/// 数据类型,如 System.String。
/// </summary>
[MaxLength(128)]
public string DataType { get; set; } = "";
/// <summary>
/// 中文名称,如 位置 X。
/// </summary>
[MaxLength(256)]
public string? Chinese { get; set; }
/// <summary>
/// 英文名称,如 Position X。
/// </summary>
[MaxLength(256)]
public string? English { get; set; }
/// <summary>
/// 其他语言名称,如 位置 X。
/// </summary>
[MaxLength(512)]
public string Other { get; set; } = "";
/// <summary>
/// 是否内置默认字段,如 true。
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// 创建时间,如 2021-01-01 12:00:00。
/// </summary>
public DateTimeOffset CreateTime { get; set; }
/// <summary>
/// 更新时间,如 2021-01-01 12:00:00。
/// </summary>
public DateTimeOffset UpdateTime { get; set; }
}
public sealed record SimpleFieldRequest(
Guid? Id,
string CarType,
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using MiGu.Server.Persistence;
using MiGu.DB.Domains.SimpleFields;
namespace MiGu.Server.SimpleFields;
+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)
+3
View File
@@ -6,6 +6,9 @@
"Yarp": "Debug"
}
},
"Database": {
"SchemaMode": "EnsureCreated"
},
"SimpleLite": {
"Enabled": true,
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
+8
View File
@@ -114,5 +114,13 @@
}
}
}
},
"Database": {
"Provider": "sqlite",
"SchemaMode": "Migrate",
"ApplyDataMigratorsOnStartup": true
},
"ConnectionStrings": {
"Platform": "Data Source=data/platform.db"
}
}
Binary file not shown.