持久层重构为独立 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
+2
View File
@@ -20,6 +20,8 @@ frontends/apps/simple-platform-vue/auto-imports.d.ts
# 临时构建输出 # 临时构建输出
.tmp-build*/ .tmp-build*/
_build_out.txt
.tools/
# SimpleLite 运行目录 # SimpleLite 运行目录
/SimpleLite/ /SimpleLite/
@@ -0,0 +1,45 @@
namespace MiGu.DB.Abstractions.Entities;
public interface IEntity<TKey>
{
TKey Id { get; set; }
}
public interface IAuditable
{
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset UpdatedAt { get; set; }
string CreatedBy { get; set; }
string UpdatedBy { get; set; }
}
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
string DeletedBy { get; set; }
}
public interface IVersioned
{
long Version { get; set; }
}
public interface ILockable
{
bool IsLock { get; set; }
}
public interface IRemarkable
{
string Remark { get; set; }
}
public interface IExtendable
{
string Extend { get; set; }
}
public interface IHistoryEntry
{
}
@@ -0,0 +1,42 @@
namespace MiGu.DB.Abstractions.Exceptions;
public sealed class ConcurrencyConflictException : Exception
{
public string EntityType { get; }
public object? EntityId { get; }
public long? ExpectedVersion { get; }
public ConcurrencyConflictException(string entityType, object? entityId, long? expectedVersion = null)
: base("数据已被其他用户修改,请刷新后重试")
{
EntityType = entityType;
EntityId = entityId;
ExpectedVersion = expectedVersion;
}
}
public sealed class EntityLockedException : Exception
{
public string EntityType { get; }
public object? EntityId { get; }
public EntityLockedException(string entityType, object? entityId)
: base("数据已锁定,不能修改")
{
EntityType = entityType;
EntityId = entityId;
}
}
public sealed class EntityNotFoundException : Exception
{
public string EntityType { get; }
public object? EntityId { get; }
public EntityNotFoundException(string entityType, object? entityId)
: base("数据不存在")
{
EntityType = entityType;
EntityId = entityId;
}
}
@@ -0,0 +1,10 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace MiGu.DB.Abstractions.Modules;
public interface IEntityModule
{
void ConfigureModel(ModelBuilder modelBuilder);
void RegisterServices(IServiceCollection services);
}
@@ -0,0 +1,32 @@
using System.Linq.Expressions;
using MiGu.DB.Abstractions.Entities;
namespace MiGu.DB.Abstractions.Persistence;
public interface IRepository<TEntity, TKey> where TEntity : class, IEntity<TKey>
{
IQueryable<TEntity> Query(bool asNoTracking = true);
Task<TEntity?> FindAsync(TKey id, CancellationToken ct = default);
Task AddAsync(TEntity entity, CancellationToken ct = default);
void Update(TEntity entity);
}
public interface IEditableRepository<TEntity> : IRepository<TEntity, Guid>
where TEntity : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
{
Task<TEntity> GetEditableAsync(Guid id, long? expectedVersion, CancellationToken ct = default);
Task SoftDeleteAsync(Guid id, long? expectedVersion, CancellationToken ct = default);
Task EnsureUniqueAsync(Expression<Func<TEntity, bool>> predicate, string errorMessage, CancellationToken ct = default);
}
public interface IHistoryRepository<TEntity> : IRepository<TEntity, Guid>
where TEntity : class, IEntity<Guid>, IHistoryEntry
{
Task AppendAsync(TEntity entry, CancellationToken ct = default);
}
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default);
}
@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
namespace MiGu.DB.Abstractions.Providers;
public interface IDbProviderSetup
{
string Name { get; }
void Configure(DbContextOptionsBuilder builder, string connectionString);
string MigrationsAssemblyName { get; }
string MigrationsNamespace { get; }
}
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
namespace MiGu.DB.Abstractions.Runtime;
public interface IActorContext
{
string Name { get; }
}
public interface IActorContextAccessor
{
IActorContext Current { get; set; }
}
public interface IDataMigrator
{
int Order { get; }
string Name { get; }
Task MigrateAsync(DbContext db, CancellationToken ct = default);
}
/// <summary>
/// 启动期 Schema 初始化策略。
/// <see cref="Migrate"/>:版本化迁移(发版/现场);
/// <see cref="EnsureCreated"/>:按当前模型建库(开发期,改模型需删库重建)。
/// </summary>
public enum MiGuSchemaMode
{
Migrate = 0,
EnsureCreated = 1
}
public sealed class MiGuDbOptions
{
public string Provider { get; set; } = "sqlite";
public string ConnectionString { get; set; } = "";
public string ContentRootPath { get; set; } = "";
/// <summary>Schema 初始化模式,对应配置 Database:SchemaMode。</summary>
public MiGuSchemaMode SchemaMode { get; set; } = MiGuSchemaMode.Migrate;
/// <summary>为 true 时,Schema 初始化后按 Order 执行全部 IDataMigrator(默认开启)。</summary>
public bool ApplyDataMigratorsOnStartup { get; set; } = true;
}
View File
@@ -1,6 +1,5 @@
namespace MiGu.Server.Dashboard; namespace MiGu.DB.Domains.Dashboard;
/// <summary>用户 Dashboard 快捷入口配置(按 user + scope 一行)。</summary>
public sealed class UserDashboardShortcut public sealed class UserDashboardShortcut
{ {
public string UserId { get; set; } = ""; public string UserId { get; set; } = "";
+105
View File
@@ -0,0 +1,105 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MiGu.DB.Abstractions.Runtime;
using MiGu.DB.Domains.Wms;
using MiGu.DB.Kernel.Context;
namespace MiGu.DB.Domains.Migrators;
// 启动期数据修补:Schema 初始化之后按 Order 执行,须幂等。
// 优先 LINQ / ExecuteUpdateAsync;枚举 converter 无法匹配旧字符串时允许定点 raw UPDATESqlite)。
/// <summary>将 simple_fields.other 回填到 car_type(替代原 PlatformPersistence raw UPDATE)。</summary>
public sealed class SimpleFieldsCarTypeBackfillMigrator : IDataMigrator
{
public int Order => 10;
public string Name => "SimpleFields.CarTypeBackfill";
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
{
if (db is not MiGuDbContext ctx) return;
await ctx.SimpleFields
.Where(x => (x.CarType == null || x.CarType == "") && x.Other != "")
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CarType, x => x.Other), ct);
await ctx.SimpleFields
.Where(x => x.Other != "" && x.Other == x.CarType)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Other, ""), ct);
}
}
/// <summary>
/// 把库内旧状态字符串刷成规范枚举名(Available/Idle→EmptyOccupied→FullContainer)。
/// 仅 Sqlite 表名/列名;绕过枚举 HasConversionExecuteUpdate + 字符串比较会 InvalidCast)。
/// </summary>
public sealed class LegacyStatusNormalizationMigrator : IDataMigrator
{
public int Order => 15;
public string Name => "Wms.LegacyStatusNormalization";
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
{
if (db is not MiGuDbContext) return;
await db.Database.ExecuteSqlRawAsync(
"""
UPDATE wms_storages
SET Status = 'Empty'
WHERE Status IN ('Available', 'Idle')
""",
ct);
await db.Database.ExecuteSqlRawAsync(
"""
UPDATE wms_storages
SET Status = 'FullContainer'
WHERE Status = 'Occupied'
""",
ct);
await db.Database.ExecuteSqlRawAsync(
"""
UPDATE wms_areas
SET LayoutMode = 'Flat'
WHERE LayoutMode IS NULL OR LayoutMode = ''
""",
ct);
}
}
/// <summary>
/// LocationType=Storage 时回填 StorageId,供库存 join;写路径 BindOrTransferLocation 也会维护该字段。
/// </summary>
public sealed class ContainerLocationStorageIdBackfillMigrator : IDataMigrator
{
public int Order => 20;
public string Name => "Wms.ContainerLocation.StorageId";
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
{
if (db is not MiGuDbContext ctx) return;
var rows = await ctx.ContainerLocations
.Where(x => x.LocationType == ContainerLocationType.Storage && x.StorageId == null)
.ToListAsync(ct);
foreach (var row in rows)
{
if (Guid.TryParse(row.LocationId, out var sid))
row.StorageId = sid;
}
if (rows.Count > 0)
await ctx.SaveChangesAsync(ct);
}
}
public static class DataMigratorRegistration
{
public static IServiceCollection AddMiGuDataMigrators(this IServiceCollection services)
{
services.AddSingleton<IDataMigrator, SimpleFieldsCarTypeBackfillMigrator>();
services.AddSingleton<IDataMigrator, LegacyStatusNormalizationMigrator>();
services.AddSingleton<IDataMigrator, ContainerLocationStorageIdBackfillMigrator>();
return services;
}
}
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using MiGu.DB.Kernel.Entities;
namespace MiGu.DB.Domains.SimpleFields;
public sealed class SimpleField : Entity<Guid>
{
[MaxLength(64)] public string CarType { get; set; } = "";
[MaxLength(64)] public string FieldType { get; set; } = "";
[MaxLength(128)] public string Key { get; set; } = "";
public string Value { get; set; } = "";
[MaxLength(128)] public string DataType { get; set; } = "";
[MaxLength(256)] public string? Chinese { get; set; }
[MaxLength(256)] public string? English { get; set; }
[MaxLength(512)] public string Other { get; set; } = "";
public bool IsDefault { get; set; }
public DateTimeOffset CreateTime { get; set; }
public DateTimeOffset UpdateTime { get; set; }
}
public static class SimpleFieldDateTime
{
public const string Format = "yyyy-MM-dd HH:mm:ss";
public static DateTimeOffset Now => DateTimeOffset.UtcNow;
public static string ToStorage(DateTimeOffset value)
=> value.UtcDateTime.ToString(Format);
public static DateTimeOffset FromStorage(string value)
=> DateTimeOffset.Parse(value);
}
@@ -0,0 +1,104 @@
using System.ComponentModel.DataAnnotations;
using MiGu.DB.Kernel.Entities;
namespace MiGu.DB.Domains.Transport;
// 运输规则/任务/预占实体。状态字段为枚举;复数 *Statuses 辅助类供 Server 解析 DTO 字符串。
public sealed class WmsTransportRule : AggregateRoot
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
public WmsTransportTriggerType TriggerType { get; set; } = WmsTransportTriggerType.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 : AggregateRoot
{
[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; }
public decimal? Quantity { get; set; }
public WmsTransportTaskStatus Status { get; set; } = WmsTransportTaskStatus.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 : AggregateRoot
{
public Guid TaskId { get; set; }
public Guid ContainerId { get; set; }
public Guid SourceStorageId { get; set; }
public Guid TargetStorageId { get; set; }
public WmsReservationStatus Status { get; set; } = WmsReservationStatus.Active;
public DateTimeOffset? ExpiresAt { get; set; }
}
public sealed class WmsTransportTaskHistory : Entity<Guid>
{
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 WmsTransportTriggerTypes
{
// 复数辅助类:与枚举分离,避免与属性/类型同名冲突,并提供 ParseOr / All
public const WmsTransportTriggerType MaterialCall = WmsTransportTriggerType.MaterialCall;
public const WmsTransportTriggerType FinishedGoodsOffline = WmsTransportTriggerType.FinishedGoodsOffline;
public const WmsTransportTriggerType AutoTransfer = WmsTransportTriggerType.AutoTransfer;
public static readonly HashSet<WmsTransportTriggerType> All = new()
{ MaterialCall, FinishedGoodsOffline, AutoTransfer };
public static bool IsDefined(string? value) =>
Enum.TryParse<WmsTransportTriggerType>(value, true, out var e) && All.Contains(e);
public static WmsTransportTriggerType ParseOr(string? value, WmsTransportTriggerType fallback = WmsTransportTriggerType.MaterialCall) =>
Enum.TryParse<WmsTransportTriggerType>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class WmsTransportTaskStatuses
{
public const WmsTransportTaskStatus Pending = WmsTransportTaskStatus.Pending;
public const WmsTransportTaskStatus Reserved = WmsTransportTaskStatus.Reserved;
public const WmsTransportTaskStatus Dispatched = WmsTransportTaskStatus.Dispatched;
public const WmsTransportTaskStatus InTransit = WmsTransportTaskStatus.InTransit;
public const WmsTransportTaskStatus Completed = WmsTransportTaskStatus.Completed;
public const WmsTransportTaskStatus Failed = WmsTransportTaskStatus.Failed;
public const WmsTransportTaskStatus Cancelled = WmsTransportTaskStatus.Cancelled;
public static readonly HashSet<WmsTransportTaskStatus> Active = new()
{ Pending, Reserved, Dispatched, InTransit };
public static readonly HashSet<WmsTransportTaskStatus> All = new()
{ Pending, Reserved, Dispatched, InTransit, Completed, Failed, Cancelled };
}
public static class WmsReservationStatuses
{
public const WmsReservationStatus Active = WmsReservationStatus.Active;
public const WmsReservationStatus Released = WmsReservationStatus.Released;
public const WmsReservationStatus Expired = WmsReservationStatus.Expired;
}
public static class WmsDispatchStatuses
{
public const WmsDispatchStatus Dispatched = WmsDispatchStatus.Dispatched;
public const WmsDispatchStatus Failed = WmsDispatchStatus.Failed;
}
@@ -0,0 +1,35 @@
namespace MiGu.DB.Domains.Transport;
/// <summary>
/// 运输域状态/触发类型枚举。存储规则同 WmsHasConversion&lt;string&gt;,成员名 = 列值。
/// </summary>
public enum WmsTransportTriggerType
{
MaterialCall,
FinishedGoodsOffline,
AutoTransfer
}
public enum WmsTransportTaskStatus
{
Pending,
Reserved,
Dispatched,
InTransit,
Completed,
Failed,
Cancelled
}
public enum WmsReservationStatus
{
Active,
Released,
Expired
}
public enum WmsDispatchStatus
{
Dispatched,
Failed
}
@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MiGu.DB.Domains.Dashboard;
using MiGu.DB.Domains.SimpleFields;
using MiGu.DB.Domains.Transport;
namespace MiGu.DB.Domains.Transport
{
public sealed class WmsTransportRuleConfiguration : IEntityTypeConfiguration<WmsTransportRule>
{
public void Configure(EntityTypeBuilder<WmsTransportRule> b)
{
b.ToTable("wms_transport_rules");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
b.HasIndex(x => new { x.TriggerType, x.Enabled, x.Priority });
b.Property(x => x.SourceSelectorJson).HasColumnType("text");
b.Property(x => x.TargetSelectorJson).HasColumnType("text");
b.Property(x => x.TaskOptionsJson).HasColumnType("text");
}
}
public sealed class WmsTransportTaskConfiguration : IEntityTypeConfiguration<WmsTransportTask>
{
public void Configure(EntityTypeBuilder<WmsTransportTask> b)
{
b.ToTable("wms_transport_tasks");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Status);
b.HasIndex(x => x.ContainerId);
b.HasIndex(x => x.TargetStorageId);
b.Property(x => x.Quantity).HasPrecision(18, 4);
b.Property(x => x.SnapshotJson).HasColumnType("text");
}
}
public sealed class WmsTransportReservationConfiguration : IEntityTypeConfiguration<WmsTransportReservation>
{
public void Configure(EntityTypeBuilder<WmsTransportReservation> b)
{
b.ToTable("wms_transport_reservations");
b.HasKey(x => x.Id);
b.HasIndex(x => new { x.ContainerId, x.Status });
b.HasIndex(x => new { x.TargetStorageId, x.Status });
}
}
public sealed class WmsTransportTaskHistoryConfiguration : IEntityTypeConfiguration<WmsTransportTaskHistory>
{
public void Configure(EntityTypeBuilder<WmsTransportTaskHistory> b)
{
b.ToTable("wms_transport_task_history");
b.HasKey(x => x.Id);
b.Property(x => x.FromStatus).HasMaxLength(32);
b.Property(x => x.ToStatus).HasMaxLength(32);
b.Property(x => x.Operator).HasMaxLength(128);
b.Property(x => x.Reason).HasMaxLength(500);
b.Property(x => x.ErrorMessage).HasMaxLength(1000);
b.Property(x => x.SnapshotJson).HasColumnType("text");
b.HasIndex(x => x.TaskId);
b.HasIndex(x => x.OperatedAt);
}
}
}
namespace MiGu.DB.Domains.SimpleFields
{
public sealed class SimpleFieldConfiguration : IEntityTypeConfiguration<SimpleField>
{
public void Configure(EntityTypeBuilder<SimpleField> b)
{
b.ToTable("simple_fields");
b.HasKey(x => x.Id);
b.Property(x => x.Id).HasColumnName("id");
b.Property(x => x.CarType).HasColumnName("car_type").HasMaxLength(64);
b.Property(x => x.FieldType).HasColumnName("field_type").HasMaxLength(64);
b.Property(x => x.Key).HasColumnName("key").HasMaxLength(128);
b.Property(x => x.Value).HasColumnName("value");
b.Property(x => x.DataType).HasColumnName("data_type").HasMaxLength(128);
b.Property(x => x.Chinese).HasColumnName("chinese").HasMaxLength(256).IsRequired(false);
b.Property(x => x.English).HasColumnName("english").HasMaxLength(256).IsRequired(false);
b.Property(x => x.Other).HasColumnName("other").HasMaxLength(512);
b.Property(x => x.IsDefault).HasColumnName("is_default");
var dateTime = new ValueConverter<DateTimeOffset, string>(
v => SimpleFieldDateTime.ToStorage(v),
v => SimpleFieldDateTime.FromStorage(v));
b.Property(x => x.CreateTime).HasColumnName("create_time").HasConversion(dateTime).HasMaxLength(19);
b.Property(x => x.UpdateTime).HasColumnName("update_time").HasConversion(dateTime).HasMaxLength(19);
b.HasIndex(x => new { x.CarType, x.FieldType, x.Key }).IsUnique();
}
}
}
namespace MiGu.DB.Domains.Dashboard
{
public sealed class UserDashboardShortcutConfiguration : IEntityTypeConfiguration<UserDashboardShortcut>
{
public void Configure(EntityTypeBuilder<UserDashboardShortcut> b)
{
b.ToTable("user_dashboard_shortcuts");
b.HasKey(x => new { x.UserId, x.Scope });
b.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64);
b.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32);
b.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text");
var dateTime = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("O"),
v => DateTimeOffset.Parse(v));
b.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40);
}
}
}
+170
View File
@@ -0,0 +1,170 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MiGu.DB.Domains.Dashboard;
using MiGu.DB.Domains.SimpleFields;
using MiGu.DB.Domains.Transport;
using MiGu.DB.Domains.Wms;
using MiGu.DB.Kernel.Entities;
namespace MiGu.DB.Domains.Wms;
public sealed class WarehouseConfiguration : IEntityTypeConfiguration<Warehouse>
{
public void Configure(EntityTypeBuilder<Warehouse> b)
{
b.ToTable("wms_warehouses");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
}
}
public sealed class WarehouseAreaConfiguration : IEntityTypeConfiguration<WarehouseArea>
{
public void Configure(EntityTypeBuilder<WarehouseArea> b)
{
b.ToTable("wms_areas");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
b.HasIndex(x => x.WarehouseId);
}
}
public sealed class StorageConfiguration : IEntityTypeConfiguration<Storage>
{
public void Configure(EntityTypeBuilder<Storage> b)
{
b.ToTable("wms_storages");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
b.HasIndex(x => x.AreaId);
b.HasIndex(x => x.Barcode);
}
}
public sealed class ContainerConfiguration : IEntityTypeConfiguration<Container>
{
public void Configure(EntityTypeBuilder<Container> b)
{
b.ToTable("wms_containers");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
}
}
public sealed class MaterialTypeConfiguration : IEntityTypeConfiguration<MaterialType>
{
public void Configure(EntityTypeBuilder<MaterialType> b)
{
b.ToTable("wms_material_types");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
}
}
public sealed class MaterialConfiguration : IEntityTypeConfiguration<Material>
{
public void Configure(EntityTypeBuilder<Material> b)
{
b.ToTable("wms_materials");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
b.HasIndex(x => x.Barcode);
b.HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt });
b.HasIndex(x => x.TypeCode);
}
}
public sealed class ContainerLocationConfiguration : IEntityTypeConfiguration<ContainerLocation>
{
public void Configure(EntityTypeBuilder<ContainerLocation> b)
{
b.ToTable("wms_container_locations");
b.HasKey(x => x.Id);
b.HasIndex(x => x.ContainerId).IsUnique();
b.HasIndex(x => new { x.LocationType, x.LocationId });
b.HasIndex(x => x.StorageId);
}
}
public sealed class ContainerMaterialConfiguration : IEntityTypeConfiguration<ContainerMaterial>
{
public void Configure(EntityTypeBuilder<ContainerMaterial> b)
{
b.ToTable("wms_container_materials");
b.HasKey(x => x.Id);
b.HasIndex(x => x.MaterialId).IsUnique();
b.HasIndex(x => x.ContainerId);
b.Property(x => x.Quantity).HasPrecision(18, 4);
}
}
public sealed class StockEventConfiguration : IEntityTypeConfiguration<StockEvent>
{
public void Configure(EntityTypeBuilder<StockEvent> b)
{
b.ToTable("wms_stock_events");
b.HasKey(x => x.Id);
b.Property(x => x.EventType).HasMaxLength(32);
b.Property(x => x.MaterialCode).HasMaxLength(64);
b.Property(x => x.MaterialName).HasMaxLength(128);
b.Property(x => x.MaterialBarcode).HasMaxLength(128);
b.Property(x => x.MaterialTypeCode).HasMaxLength(64);
b.Property(x => x.ContainerCode).HasMaxLength(64);
b.Property(x => x.ContainerName).HasMaxLength(128);
b.Property(x => x.StorageCode).HasMaxLength(64);
b.Property(x => x.StorageName).HasMaxLength(128);
b.Property(x => x.AreaCode).HasMaxLength(64);
b.Property(x => x.FromStorageCode).HasMaxLength(64);
b.Property(x => x.FromStorageName).HasMaxLength(128);
b.Property(x => x.ToStorageCode).HasMaxLength(64);
b.Property(x => x.ToStorageName).HasMaxLength(128);
b.Property(x => x.RefType).HasMaxLength(64);
b.Property(x => x.RefCode).HasMaxLength(64);
b.Property(x => x.Operator).HasMaxLength(128);
b.Property(x => x.Reason).HasMaxLength(500);
b.HasIndex(x => x.OperatedAt);
b.HasIndex(x => x.EventType);
b.HasIndex(x => x.MaterialId);
b.HasIndex(x => x.ContainerId);
}
}
public abstract class HistoryConfigurationBase<T> : IEntityTypeConfiguration<T> where T : HistoryEntity
{
private readonly string _table;
protected HistoryConfigurationBase(string table) => _table = table;
public virtual void Configure(EntityTypeBuilder<T> b)
{
b.ToTable(_table);
b.HasKey(x => x.Id);
b.Property(x => x.EventType).HasMaxLength(64);
b.Property(x => x.BeforeJson).HasColumnType("text");
b.Property(x => x.AfterJson).HasColumnType("text");
b.Property(x => x.Operator).HasMaxLength(128);
b.Property(x => x.Source).HasMaxLength(32);
b.Property(x => x.Reason).HasMaxLength(500);
b.Property(x => x.Remark).HasMaxLength(1000);
b.Property(x => x.Extend).HasColumnType("text");
b.HasIndex(x => x.ContainerId);
b.HasIndex(x => x.OperatedAt);
b.HasIndex(x => x.EventType);
}
}
public sealed class ContainerLocationHistoryConfiguration : HistoryConfigurationBase<ContainerLocationHistory>
{
public ContainerLocationHistoryConfiguration() : base("wms_container_location_history") { }
}
public sealed class ContainerMaterialHistoryConfiguration : HistoryConfigurationBase<ContainerMaterialHistory>
{
public ContainerMaterialHistoryConfiguration() : base("wms_container_material_history") { }
public override void Configure(EntityTypeBuilder<ContainerMaterialHistory> b)
{
base.Configure(b);
b.Property(x => x.QuantityDelta).HasPrecision(18, 4);
}
}
+174
View File
@@ -0,0 +1,174 @@
using System.ComponentModel.DataAnnotations;
using MiGu.DB.Kernel.Entities;
namespace MiGu.DB.Domains.Wms;
// WMS 聚合实体。状态/类型字段为枚举(库内 TEXT,见全局 HasConversion);DTO 仍用字符串,由 *Statuses 辅助类解析。
public sealed class Warehouse : AggregateRoot
{
[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 class WarehouseArea : AggregateRoot
{
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";
public AreaLayoutMode LayoutMode { get; set; } = AreaLayoutMode.Flat;
[MaxLength(32)] public string State { get; set; } = "Default";
public bool Enabled { get; set; } = true;
public int SortOrder { get; set; }
}
public sealed class Storage : AggregateRoot
{
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; } = StorageTypeCodes.Storage;
// 属性名与枚举类型同名合法;默认值引用枚举成员 LocationKind.Station(勿再引入同名静态类)
public LocationKind LocationKind { get; set; } = LocationKind.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; } = "";
public int Capacity { get; set; }
public StorageStatus Status { get; set; } = StorageStatus.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 class Container : AggregateRoot
{
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";
public ContainerStatus Status { get; set; } = ContainerStatus.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 sealed class MaterialType : AggregateRoot
{
[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 class Material : AggregateRoot
{
[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; } = "";
public MaterialLifecycle LifecycleStatus { get; set; } = MaterialLifecycle.Active;
public DateTimeOffset? UnboundAt { get; set; }
public bool Enabled { get; set; } = true;
}
public sealed class ContainerLocation : AggregateRoot
{
public Guid ContainerId { get; set; }
public ContainerLocationType LocationType { get; set; } = ContainerLocationType.Storage;
[MaxLength(64)] public string LocationId { get; set; } = "";
/// <summary>LocationType=Storage 时的库位 Id;由 LocationId 回填,供库存 join 下推。</summary>
public Guid? StorageId { get; set; }
[MaxLength(64)] public string LocationCode { get; set; } = "";
[MaxLength(128)] public string LocationName { get; set; } = "";
public ContainerLocationStatus Status { get; set; } = ContainerLocationStatus.Active;
public DateTimeOffset EnteredAt { get; set; }
}
public sealed class ContainerMaterial : AggregateRoot
{
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; } = "";
public ContainerMaterialStatus Status { get; set; } = ContainerMaterialStatus.Bound;
public DateTimeOffset BoundAt { get; set; }
public DateTimeOffset LoadedAt { get; set; }
public DateTimeOffset? UnloadedAt { get; set; }
}
public sealed class StockEvent : Kernel.Entities.Entity<Guid>
{
public StockEventType 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 class ContainerLocationHistory : HistoryEntity
{
[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 class ContainerMaterialHistory : HistoryEntity
{
public Guid? MaterialId { get; set; }
public decimal QuantityDelta { get; set; }
}
public static class StorageTypeCodes
{
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 WmsDefaults
{
public const string DefaultWarehouseCode = "DEFAULT";
public const string DefaultWarehouseName = "默认仓库";
}
+66
View File
@@ -0,0 +1,66 @@
namespace MiGu.DB.Domains.Wms;
/// <summary>
/// WMS 状态/类型枚举。列以字符串存储(见约定 HasConversion&lt;string&gt;),成员名即库内合法值。
/// 旧别名(Available/Idle/Occupied 等)不进入枚举,由 LegacyStatusNormalizationMigrator 刷库。
/// </summary>
public enum AreaLayoutMode
{
Flat,
Grid
}
public enum LocationKind
{
Grid,
Station
}
public enum StorageStatus
{
Empty,
EmptyContainer,
FullContainer,
Disabled
}
public enum ContainerStatus
{
EmptyMaterial,
FullMaterial
}
public enum ContainerLocationType
{
Storage,
Car
}
public enum ContainerLocationStatus
{
Active,
Locked,
Exception
}
public enum ContainerMaterialStatus
{
Bound,
Loaded,
Unloaded,
Adjusted,
Frozen
}
public enum MaterialLifecycle
{
Active,
Archived
}
public enum StockEventType
{
Bind,
Unbind,
ContainerMove
}
+116
View File
@@ -0,0 +1,116 @@
namespace MiGu.DB.Domains.Wms;
/// <summary>
/// 枚举辅助类(复数命名):承接 API/DTO 的字符串入参,解析为实体上的枚举。
/// 与同名枚举分离,避免「属性 LocationKind 初始值引用实例属性」一类编译冲突。
/// Server 侧通过 global using 别名(如 StorageStatuses)引用本文件类型。
/// </summary>
public static class AreaLayoutModes
{
public static readonly HashSet<AreaLayoutMode> All = new() { AreaLayoutMode.Flat, AreaLayoutMode.Grid };
public static AreaLayoutMode ParseOr(string? value, AreaLayoutMode fallback = AreaLayoutMode.Flat) =>
Enum.TryParse<AreaLayoutMode>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class LocationKinds
{
public const LocationKind Grid = LocationKind.Grid;
public const LocationKind Station = LocationKind.Station;
public static readonly HashSet<LocationKind> All = new() { Grid, Station };
public static LocationKind ParseOr(string? value, LocationKind fallback = LocationKind.Station) =>
Enum.TryParse<LocationKind>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class StorageStatuses
{
public const StorageStatus Empty = StorageStatus.Empty;
public const StorageStatus EmptyContainer = StorageStatus.EmptyContainer;
public const StorageStatus FullContainer = StorageStatus.FullContainer;
public const StorageStatus Disabled = StorageStatus.Disabled;
public static readonly HashSet<StorageStatus> All = new()
{ Empty, EmptyContainer, FullContainer, Disabled };
/// <summary>
/// 写路径归一:兼容历史字符串 Available/Idle/Occupied。
/// 库内残留旧值须靠 DataMigrator 刷掉,不能依赖 converter 读侧归一(WHERE 会漏行)。
/// </summary>
public static StorageStatus Normalize(string? status) => status switch
{
"Available" or "Idle" => Empty,
"Occupied" => FullContainer,
_ when Enum.TryParse<StorageStatus>(status, true, out var e) && All.Contains(e) => e,
_ => Empty
};
}
public static class ContainerStatuses
{
public const ContainerStatus EmptyMaterial = ContainerStatus.EmptyMaterial;
public const ContainerStatus FullMaterial = ContainerStatus.FullMaterial;
public static readonly HashSet<ContainerStatus> All = new() { EmptyMaterial, FullMaterial };
public static ContainerStatus ParseOr(string? value, ContainerStatus fallback = ContainerStatus.EmptyMaterial) =>
Enum.TryParse<ContainerStatus>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class ContainerLocationTypes
{
public const ContainerLocationType Storage = ContainerLocationType.Storage;
public const ContainerLocationType Car = ContainerLocationType.Car;
public static readonly HashSet<ContainerLocationType> All = new() { Storage, Car };
public static bool IsDefined(string? value) =>
Enum.TryParse<ContainerLocationType>(value, true, out var e) && All.Contains(e);
public static ContainerLocationType ParseOr(string? value, ContainerLocationType fallback = ContainerLocationType.Storage) =>
Enum.TryParse<ContainerLocationType>(value, true, out var e) && All.Contains(e) ? e : fallback;
public static bool EqualsString(ContainerLocationType value, string? other) =>
Enum.TryParse<ContainerLocationType>(other, true, out var e) && e == value;
}
public static class ContainerLocationStatuses
{
public const ContainerLocationStatus Active = ContainerLocationStatus.Active;
public const ContainerLocationStatus Locked = ContainerLocationStatus.Locked;
public const ContainerLocationStatus Exception = ContainerLocationStatus.Exception;
public static readonly HashSet<ContainerLocationStatus> All = new() { Active, Locked, Exception };
public static bool IsDefined(string? value) =>
Enum.TryParse<ContainerLocationStatus>(value, true, out var e) && All.Contains(e);
public static ContainerLocationStatus ParseOr(string? value, ContainerLocationStatus fallback = ContainerLocationStatus.Active) =>
Enum.TryParse<ContainerLocationStatus>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class ContainerMaterialStatuses
{
public const ContainerMaterialStatus Bound = ContainerMaterialStatus.Bound;
public const ContainerMaterialStatus Loaded = ContainerMaterialStatus.Loaded;
public const ContainerMaterialStatus Unloaded = ContainerMaterialStatus.Unloaded;
public const ContainerMaterialStatus Adjusted = ContainerMaterialStatus.Adjusted;
public const ContainerMaterialStatus Frozen = ContainerMaterialStatus.Frozen;
public static readonly HashSet<ContainerMaterialStatus> ActiveBind = new()
{ Bound, Loaded, Adjusted, Frozen };
}
public static class MaterialLifecycles
{
public const MaterialLifecycle Active = MaterialLifecycle.Active;
public const MaterialLifecycle Archived = MaterialLifecycle.Archived;
public static readonly HashSet<MaterialLifecycle> All = new() { Active, Archived };
public static MaterialLifecycle ParseOr(string? value, MaterialLifecycle fallback = MaterialLifecycle.Active) =>
Enum.TryParse<MaterialLifecycle>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class StockEventTypes
{
public const StockEventType Bind = StockEventType.Bind;
public const StockEventType Unbind = StockEventType.Unbind;
public const StockEventType ContainerMove = StockEventType.ContainerMove;
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using MiGu.DB.Domains.Dashboard;
using MiGu.DB.Domains.SimpleFields;
using MiGu.DB.Domains.Transport;
using MiGu.DB.Domains.Wms;
namespace MiGu.DB.Kernel.Context;
public partial class MiGuDbContext
{
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>();
}
+25
View File
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using MiGu.DB.Abstractions.Modules;
using MiGu.DB.Kernel.Conventions;
namespace MiGu.DB.Kernel.Context;
public partial class MiGuDbContext : DbContext
{
private readonly IEnumerable<IEntityModule> _modules;
public MiGuDbContext(DbContextOptions<MiGuDbContext> options, IEnumerable<IEntityModule>? modules = null)
: base(options)
{
_modules = modules ?? Array.Empty<IEntityModule>();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 先约定、后 Configuration,让实体级配置覆盖全局 Guid/时间转换(如 SimpleField
ModelConventionExtensions.ApplyMiGuConventions(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MiGuDbContext).Assembly);
foreach (var module in _modules)
module.ConfigureModel(modelBuilder);
}
}
+167
View File
@@ -0,0 +1,167 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MiGu.DB.Abstractions.Entities;
using MiGu.DB.Abstractions.Runtime;
namespace MiGu.DB.Kernel.Conventions;
/// <summary>
/// 全局模型约定:Guid/DateTimeOffset 字符串化、枚举存字符串、软删过滤、Version 并发令牌等。
/// </summary>
public static class ModelConventionExtensions
{
public static void ApplyMiGuConventions(ModelBuilder modelBuilder)
{
// 与历史 Sqlite TEXT 列兼容:Guid / DateTimeOffset 均以字符串落库
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 dto = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("O"),
v => DateTimeOffset.Parse(v));
var nullableDto = new ValueConverter<DateTimeOffset?, string?>(
v => v.HasValue ? v.Value.UtcDateTime.ToString("O") : null,
v => string.IsNullOrWhiteSpace(v) ? null : DateTimeOffset.Parse(v));
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var clr = entityType.ClrType;
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(Guid)))
modelBuilder.Entity(clr).Property(p.Name).HasConversion(guid).HasMaxLength(36);
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(Guid?)))
modelBuilder.Entity(clr).Property(p.Name).HasConversion(nullableGuid).HasMaxLength(36);
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset)))
modelBuilder.Entity(clr).Property(p.Name).HasConversion(dto).HasMaxLength(40);
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset?)))
modelBuilder.Entity(clr).Property(p.Name).HasConversion(nullableDto).HasMaxLength(40);
// 严格 1:1 字符串转换;旧值兼容靠 IDataMigrator,禁止在 converter 读侧 Normalize
foreach (var p in clr.GetProperties().Where(p => p.PropertyType.IsEnum))
modelBuilder.Entity(clr).Property(p.Name).HasConversion<string>().HasMaxLength(32);
if (typeof(ISoftDeletable).IsAssignableFrom(clr))
{
var method = typeof(ModelConventionExtensions)
.GetMethod(nameof(SetSoftDeleteFilter), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!
.MakeGenericMethod(clr);
method.Invoke(null, new object[] { modelBuilder });
}
// Version:业务乐观锁(非数据库 rowversion 字节数组)
if (typeof(IVersioned).IsAssignableFrom(clr))
modelBuilder.Entity(clr).Property(nameof(IVersioned.Version)).IsConcurrencyToken();
if (typeof(ILockable).IsAssignableFrom(clr))
modelBuilder.Entity(clr).Property(nameof(ILockable.IsLock)).HasColumnName("IsLock");
if (typeof(IAuditable).IsAssignableFrom(clr))
{
modelBuilder.Entity(clr).Property(nameof(IAuditable.CreatedBy)).HasMaxLength(128);
modelBuilder.Entity(clr).Property(nameof(IAuditable.UpdatedBy)).HasMaxLength(128);
}
if (typeof(ISoftDeletable).IsAssignableFrom(clr))
modelBuilder.Entity(clr).Property(nameof(ISoftDeletable.DeletedBy)).HasMaxLength(128);
if (typeof(IRemarkable).IsAssignableFrom(clr))
modelBuilder.Entity(clr).Property(nameof(IRemarkable.Remark)).HasMaxLength(1000);
if (typeof(IExtendable).IsAssignableFrom(clr))
modelBuilder.Entity(clr).Property(nameof(IExtendable.Extend)).HasColumnType("text");
}
}
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : class, ISoftDeletable
=> modelBuilder.Entity<TEntity>().HasQueryFilter(e => !e.IsDeleted);
}
public sealed class ActorContextAccessor : IActorContextAccessor
{
private static readonly AsyncLocal<IActorContext?> CurrentContext = new();
public IActorContext Current
{
get => CurrentContext.Value ?? SystemActorContext.Instance;
set => CurrentContext.Value = value;
}
}
public sealed class SystemActorContext : IActorContext
{
public static readonly SystemActorContext Instance = new();
public string Name => "system";
}
public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
private readonly IActorContextAccessor _actors;
public AuditSaveChangesInterceptor(IActorContextAccessor actors) => _actors = actors;
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
Stamp(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
Stamp(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void Stamp(DbContext? db)
{
if (db is null) return;
var now = DateTimeOffset.UtcNow;
var actor = _actors.Current.Name;
foreach (var entry in db.ChangeTracker.Entries())
{
if (entry.Entity is IAuditable auditable)
{
if (entry.State == EntityState.Added)
{
if (entry.Entity is IEntity<Guid> { Id: var id } && id == Guid.Empty)
((IEntity<Guid>)entry.Entity).Id = Guid.NewGuid();
auditable.CreatedAt = now;
auditable.UpdatedAt = now;
if (string.IsNullOrWhiteSpace(auditable.CreatedBy)) auditable.CreatedBy = actor;
if (string.IsNullOrWhiteSpace(auditable.UpdatedBy)) auditable.UpdatedBy = actor;
}
else if (entry.State == EntityState.Modified)
{
auditable.UpdatedAt = now;
auditable.UpdatedBy = actor;
}
}
if (entry.Entity is IVersioned versioned)
{
if (entry.State == EntityState.Added)
versioned.Version = Math.Max(1, versioned.Version);
else if (entry.State == EntityState.Modified)
versioned.Version += 1;
}
if (entry.Entity is IExtendable extendable && string.IsNullOrWhiteSpace(extendable.Extend))
extendable.Extend = "{}";
if (entry.Entity is ISoftDeletable soft
&& entry.State == EntityState.Modified
&& entry.Property(nameof(ISoftDeletable.IsDeleted)).IsModified
&& soft.IsDeleted)
{
soft.DeletedAt ??= now;
if (string.IsNullOrWhiteSpace(soft.DeletedBy)) soft.DeletedBy = actor;
}
}
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using MiGu.DB.Kernel.Context;
namespace MiGu.DB.Kernel.Design;
/// <summary>
/// design-time 工厂(dotnet ef migrations add)。指定 MigrationsAssembly,与运行时 SqliteProviderSetup 一致。
/// </summary>
public sealed class MiGuDbContextFactory : IDesignTimeDbContextFactory<MiGuDbContext>
{
public MiGuDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<MiGuDbContext>()
.UseSqlite("Data Source=platform.db", o =>
o.MigrationsAssembly(typeof(MiGuDbContext).Assembly.GetName().Name))
.Options;
return new MiGuDbContext(options);
}
}
+46
View File
@@ -0,0 +1,46 @@
using MiGu.DB.Abstractions.Entities;
namespace MiGu.DB.Kernel.Entities;
public abstract class Entity<TKey> : IEntity<TKey>
{
public TKey Id { get; set; } = default!;
}
public abstract class AuditedEntity : Entity<Guid>, IAuditable
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string CreatedBy { get; set; } = "";
public string UpdatedBy { get; set; } = "";
}
public abstract class SoftDeletableEntity : AuditedEntity, ISoftDeletable, IVersioned, ILockable
{
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 abstract class AggregateRoot : SoftDeletableEntity, IRemarkable, IExtendable
{
public string Remark { get; set; } = "";
public string Extend { get; set; } = "{}";
}
public abstract class HistoryEntity : Entity<Guid>, IHistoryEntry
{
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; } = "{}";
}
@@ -0,0 +1,224 @@
using Microsoft.Extensions.Logging;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using MiGu.DB.Abstractions.Modules;
using MiGu.DB.Abstractions.Persistence;
using MiGu.DB.Abstractions.Providers;
using MiGu.DB.Abstractions.Runtime;
using MiGu.DB.Kernel.Context;
using MiGu.DB.Kernel.Conventions;
using MiGu.DB.Kernel.Providers;
using MiGu.DB.Kernel.Repositories;
using MiGu.DB.Domains.Migrators;
namespace MiGu.DB.Kernel.Hosting;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMiGuDb(
this IServiceCollection services,
IConfiguration configuration,
Action<MiGuDbOptions>? configure = null)
{
var options = new MiGuDbOptions
{
Provider = configuration["Database:Provider"] ?? "sqlite",
ConnectionString = configuration.GetConnectionString("Platform")
?? configuration.GetConnectionString(ConnectionKey(configuration["Database:Provider"] ?? "sqlite"))
?? "",
SchemaMode = ParseSchemaMode(configuration["Database:SchemaMode"]),
ApplyDataMigratorsOnStartup = configuration.GetValue("Database:ApplyDataMigratorsOnStartup", true)
};
configure?.Invoke(options);
services.AddSingleton(options);
services.TryAddSingleton<IActorContextAccessor, ActorContextAccessor>();
services.TryAddScoped<IActorContext>(sp => sp.GetRequiredService<IActorContextAccessor>().Current);
services.AddSingleton<AuditSaveChangesInterceptor>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, SqliteProviderSetup>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, MySqlProviderSetup>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, NpgsqlProviderSetup>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, SqlServerProviderSetup>());
services.AddDbContext<MiGuDbContext>((sp, builder) =>
{
var opt = sp.GetRequiredService<MiGuDbOptions>();
if (string.IsNullOrWhiteSpace(opt.ContentRootPath))
{
var env = sp.GetService<IHostEnvironment>();
opt.ContentRootPath = env?.ContentRootPath ?? AppContext.BaseDirectory;
}
var providerName = NormalizeProviderName(opt.Provider);
var setup = sp.GetServices<IDbProviderSetup>()
.FirstOrDefault(p => string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException($"未知数据库 Provider: {opt.Provider}");
setup.Configure(builder, ResolveConnectionString(opt, providerName));
builder.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>());
});
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>));
services.AddScoped(typeof(IEditableRepository<>), typeof(EditableRepository<>));
services.AddScoped(typeof(IHistoryRepository<>), typeof(HistoryRepository<>));
services.AddMiGuDataMigrators();
return services;
}
public static IServiceCollection AddMiGuEntityModule<TModule>(this IServiceCollection services)
where TModule : class, IEntityModule, new()
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IEntityModule, TModule>());
new TModule().RegisterServices(services);
return services;
}
private static string NormalizeProviderName(string provider) => provider.Trim().ToLowerInvariant() switch
{
"postgres" or "postgresql" => "npgsql",
"mssql" => "sqlserver",
var x => x
};
private static MiGuSchemaMode ParseSchemaMode(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return MiGuSchemaMode.Migrate;
if (Enum.TryParse<MiGuSchemaMode>(value.Trim(), ignoreCase: true, out var mode))
return mode;
// 兼容简写
return value.Trim().ToLowerInvariant() switch
{
"ensure" or "created" or "ensure-created" => MiGuSchemaMode.EnsureCreated,
"migrations" or "ef" => MiGuSchemaMode.Migrate,
_ => throw new InvalidOperationException(
$"未知 Database:SchemaMode '{value}',允许值:Migrate、EnsureCreated")
};
}
private static string ConnectionKey(string provider) => NormalizeProviderName(provider) switch
{
"npgsql" => "PostgreSQL",
"sqlserver" => "SqlServer",
"mysql" => "MySql",
_ => "Sqlite"
};
private static string ResolveConnectionString(MiGuDbOptions options, string providerName)
{
if (!string.IsNullOrWhiteSpace(options.ConnectionString))
{
return providerName == "sqlite"
? NormalizeSqliteConnection(options.ConnectionString, options.ContentRootPath)
: options.ConnectionString;
}
var dataDir = Path.Combine(options.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
return $"Data Source={Path.Combine(dataDir, "platform.db")}";
}
private static string NormalizeSqliteConnection(string connection, string contentRoot)
{
var builder = new SqliteConnectionStringBuilder(connection);
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource is ":memory:")
return connection;
if (!Path.IsPathRooted(builder.DataSource))
builder.DataSource = Path.Combine(contentRoot, builder.DataSource);
var dir = Path.GetDirectoryName(builder.DataSource);
if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir);
return builder.ToString();
}
}
public static class DatabaseInitializer
{
/// <summary>
/// 启动期数据库初始化:按 SchemaMode 建库/迁移,再可选执行 DataMigrator。
/// </summary>
public static async Task MigrateMiGuDbAsync(this IServiceProvider services, CancellationToken ct = default)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
var options = scope.ServiceProvider.GetRequiredService<MiGuDbOptions>();
var logger = scope.ServiceProvider.GetService<ILoggerFactory>()?.CreateLogger("MiGu.DB.DatabaseInitializer");
if (options.SchemaMode == MiGuSchemaMode.EnsureCreated)
{
logger?.LogWarning(
"Database:SchemaMode=EnsureCreated:仅按当前模型建库,不应用 Migrations。" +
"改实体后若结构未更新,请删除平台库文件后重启。发版请改用 Migrate。");
await db.Database.EnsureCreatedAsync(ct);
}
else
{
var applied = (await db.Database.GetAppliedMigrationsAsync(ct)).ToList();
var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList();
if (applied.Count == 0 && pending.Count == 0)
{
logger?.LogWarning("程序集内无 Migration,回退 EnsureCreated。");
await db.Database.EnsureCreatedAsync(ct);
}
else
{
// 旧库(EnsureCreated)无历史表行时写入基线,再 Migrate
await BaselineExistingDatabaseAsync(db, logger, ct);
await db.Database.MigrateAsync(ct);
}
}
if (!options.ApplyDataMigratorsOnStartup) return;
foreach (var migrator in scope.ServiceProvider.GetServices<IDataMigrator>().OrderBy(m => m.Order))
await migrator.MigrateAsync(db, ct);
}
/// <summary>
/// 已有表结构但无 __EFMigrationsHistory 时:将<strong>全部</strong> pending Migration 记为已应用。
/// 前提:库由 EnsureCreated 按「当前模型 tip」建成,与最新 Snapshot 一致;否则应删库后走 Migrate,或手工对齐。
/// </summary>
private static async Task BaselineExistingDatabaseAsync(
MiGuDbContext db, ILogger? logger, CancellationToken ct)
{
var applied = await db.Database.GetAppliedMigrationsAsync(ct);
if (applied.Any()) return;
var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList();
if (pending.Count == 0) return;
var creator = db.GetService<IRelationalDatabaseCreator>();
if (!await creator.ExistsAsync(ct) || !await creator.HasTablesAsync(ct))
return;
var history = db.GetService<IHistoryRepository>();
var productVersion = ProductInfo.GetEFCoreVersion();
logger?.LogWarning(
"检测到已有表且无迁移历史,将 {Count} 个 pending Migration 全部写入基线(假定库结构已对齐模型 tip):{Ids}",
pending.Count, string.Join(", ", pending));
foreach (var migrationId in pending)
{
var sql = history.GetInsertScript(new HistoryRow(migrationId, productVersion));
await db.Database.ExecuteSqlRawAsync(sql, ct);
}
}
}
internal static class ProductInfo
{
public static string GetEFCoreVersion()
{
var asm = typeof(DbContext).Assembly.GetName().Version;
return asm?.ToString(3) ?? "8.0.10";
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using MiGu.DB.Abstractions.Providers;
using MiGu.DB.Kernel.Context;
namespace MiGu.DB.Kernel.Providers;
public sealed class SqliteProviderSetup : IDbProviderSetup
{
public string Name => "sqlite";
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
public string MigrationsNamespace => "MiGu.DB.Migrations.Sqlite";
public void Configure(DbContextOptionsBuilder builder, string connectionString)
=> builder.UseSqlite(connectionString, o => o.MigrationsAssembly(MigrationsAssemblyName)
.MigrationsHistoryTable("__EFMigrationsHistory"));
}
public sealed class MySqlProviderSetup : IDbProviderSetup
{
public string Name => "mysql";
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
public string MigrationsNamespace => "MiGu.DB.Migrations.MySql";
public void Configure(DbContextOptionsBuilder builder, string connectionString)
=> builder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString),
o => o.MigrationsAssembly(MigrationsAssemblyName));
}
public sealed class NpgsqlProviderSetup : IDbProviderSetup
{
public string Name => "npgsql";
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
public string MigrationsNamespace => "MiGu.DB.Migrations.Npgsql";
public void Configure(DbContextOptionsBuilder builder, string connectionString)
=> builder.UseNpgsql(connectionString, o => o.MigrationsAssembly(MigrationsAssemblyName));
}
public sealed class SqlServerProviderSetup : IDbProviderSetup
{
public string Name => "sqlserver";
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
public string MigrationsNamespace => "MiGu.DB.Migrations.SqlServer";
public void Configure(DbContextOptionsBuilder builder, string connectionString)
=> builder.UseSqlServer(connectionString, o => o.MigrationsAssembly(MigrationsAssemblyName));
}
+116
View File
@@ -0,0 +1,116 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using MiGu.DB.Abstractions.Entities;
using MiGu.DB.Abstractions.Exceptions;
using MiGu.DB.Abstractions.Persistence;
using MiGu.DB.Abstractions.Runtime;
using MiGu.DB.Kernel.Context;
namespace MiGu.DB.Kernel.Repositories;
public class Repository<TEntity, TKey> : IRepository<TEntity, TKey>
where TEntity : class, IEntity<TKey>
{
protected readonly MiGuDbContext Db;
protected DbSet<TEntity> Set => Db.Set<TEntity>();
public Repository(MiGuDbContext db) => Db = db;
public virtual IQueryable<TEntity> Query(bool asNoTracking = true)
=> asNoTracking ? Set.AsNoTracking() : Set.AsQueryable();
public virtual Task<TEntity?> FindAsync(TKey id, CancellationToken ct = default)
=> Set.FindAsync([id], ct).AsTask();
public virtual Task AddAsync(TEntity entity, CancellationToken ct = default)
=> Set.AddAsync(entity, ct).AsTask();
public virtual void Update(TEntity entity) => Set.Update(entity);
}
public sealed class EditableRepository<TEntity> : Repository<TEntity, Guid>, IEditableRepository<TEntity>
where TEntity : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
{
private readonly IActorContextAccessor _actors;
public EditableRepository(MiGuDbContext db, IActorContextAccessor actors) : base(db)
=> _actors = actors;
public async Task<TEntity> GetEditableAsync(Guid id, long? expectedVersion, CancellationToken ct = default)
{
var entity = await Set.FirstOrDefaultAsync(x => x.Id.Equals(id), ct)
?? throw new EntityNotFoundException(typeof(TEntity).Name, id);
if (entity.IsLock)
throw new EntityLockedException(typeof(TEntity).Name, id);
if (expectedVersion.HasValue && entity.Version != expectedVersion.Value)
throw new ConcurrencyConflictException(typeof(TEntity).Name, id, expectedVersion);
return entity;
}
public async Task SoftDeleteAsync(Guid id, long? expectedVersion, CancellationToken ct = default)
{
var entity = await GetEditableAsync(id, expectedVersion, ct);
entity.IsDeleted = true;
entity.DeletedAt = DateTimeOffset.UtcNow;
entity.DeletedBy = _actors.Current.Name;
if (entity is IAuditable auditable)
auditable.UpdatedBy = _actors.Current.Name;
}
public async Task EnsureUniqueAsync(Expression<Func<TEntity, bool>> predicate, string errorMessage, CancellationToken ct = default)
{
if (await Set.AnyAsync(predicate, ct))
throw new InvalidOperationException(errorMessage);
}
}
public sealed class HistoryRepository<TEntity> : Repository<TEntity, Guid>, IHistoryRepository<TEntity>
where TEntity : class, IEntity<Guid>, IHistoryEntry
{
public HistoryRepository(MiGuDbContext db) : base(db) { }
public Task AppendAsync(TEntity entry, CancellationToken ct = default)
=> AddAsync(entry, ct);
}
public sealed class UnitOfWork : IUnitOfWork
{
private readonly MiGuDbContext _db;
public UnitOfWork(MiGuDbContext db) => _db = db;
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
try
{
return await _db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.FirstOrDefault();
throw new ConcurrencyConflictException(
entry?.Entity.GetType().Name ?? "Unknown",
entry?.Property("Id")?.CurrentValue);
}
}
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
{
var strategy = _db.Database.CreateExecutionStrategy();
return strategy.ExecuteAsync(async () =>
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
try
{
await action(ct);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
});
}
}
+221
View File
@@ -0,0 +1,221 @@
# MiGu.DB Migrations 使用手册
面向日常改表、发版与排错。当前默认 Provider 为 **Sqlite**,迁移目录为 `Migrations/Sqlite/`
---
## 1. 概念速览
| 概念 | 说明 |
|------|------|
| Migration | 一次 Schema 变更(建表/加列/索引等),对应一对 `*_Name.cs` + `*_Name.Designer.cs` |
| ModelSnapshot | `MiGuDbContextModelSnapshot.cs`,当前模型总快照;下次 `add` 时与代码模型做 diff |
| `__EFMigrationsHistory` | 数据库内表,记录已应用的 Migration Id |
| DataMigrator | **数据**修补(刷旧状态、回填列),不是 Schema;启动时在 Migrate 之后执行 |
**原则:Schema 只走 Migrations;业务代码禁止手写 CREATE/ALTER。**
三个文件职责:
- `YYYYMMDDHHMMSS_Name.cs``Up()`/`Down()`,真正改库
- `YYYYMMDDHHMMSS_Name.Designer.cs` → 该次迁移的目标模型元数据(勿手改)
- `MiGuDbContextModelSnapshot.cs` → 全库最新快照(勿手改,除非处理合并冲突)
---
## 2. 环境准备
### 2.1 工具
仓库根目录(`Migu2.0`)执行:
```powershell
# 全局工具(任选)
dotnet tool install --global dotnet-ef --version 8.0.10
# 或本地工具目录(本仓库曾用此方式)
dotnet tool install dotnet-ef --version 8.0.10 --tool-path .\.tools
.\.tools\dotnet-ef --version
```
版本需与项目 EF Core **8.0.x** 对齐。
### 2.2 工程关系
| 参数 | 值 |
|------|-----|
| 迁移所在工程 | `MiGu.DB` |
| 启动工程 | `MiGu.Server`(提供配置与 Design 包) |
| DbContext | `MiGu.DB.Kernel.Context.MiGuDbContext` |
| Design-time 工厂 | `MiGu.DB.Kernel.Design.MiGuDbContextFactory` |
`MiGu.Server.csproj` 已引用 `Microsoft.EntityFrameworkCore.Design``MiGu.DB` 含 Sqlite 等 Provider 包。
---
## 3. 生成 Migration(日常流程)
### 3.1 改模型
`MiGu.DB/Domains` 改实体或 `IEntityTypeConfiguration`,保存后编译通过:
```powershell
dotnet build .\MiGu.DB\MiGu.DB.csproj
```
### 3.2 添加迁移
在**解决方案根目录**执行(PowerShell):
```powershell
dotnet ef migrations add <迁移名称> `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--output-dir Migrations/Sqlite `
--namespace MiGu.DB.Migrations.Sqlite `
--context MiGuDbContext
```
命名建议(PascalCase,无空格):
| 场景 | 示例名 |
|------|--------|
| 首库 | `InitialPlatform`(已存在,勿重复) |
| 加表 | `AddWmsXxxTable` |
| 加列 | `AddStoragePriorityColumn` |
| 加索引 | `AddStockEventOperatedAtIndex` |
### 3.3 生成后检查(必做)
1. 新文件应在:`MiGu.DB/Migrations/Sqlite/`
2. 打开 `*_Name.cs`,确认 `Up()` 只包含**本次预期**变更(无误删表、无多余重建)
3. 确认 `MiGuDbContextModelSnapshot.cs` 仍在 `Migrations/Sqlite/`
- 若出现在 `MiGu.DB/MiGu/DB/Migrations/Sqlite/` 等错误路径:把 Snapshot **移回**正确目录并删掉空目录(`--namespace` 偶发路径问题)
### 3.4 应用到本地库
启动 `MiGu.Server` 即可(`EnsurePlatformDatabaseAsync``MigrateMiGuDbAsync`),或:
```powershell
dotnet ef database update `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext
```
---
## 4. 常用命令
```powershell
# 列出迁移
dotnet ef migrations list `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext
# 生成 SQL 脚本(发版/DBA 审阅,不直接连库执行也可)
dotnet ef migrations script `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext `
--output .\MiGu.DB\Migrations\Sqlite\script.sql
# 从某迁移到最新(含幂等脚本时加 --idempotent
dotnet ef migrations script FromMigration ToMigration `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext `
--idempotent
# 删除「尚未应用到任何重要库」的最后一次迁移(仅开发)
dotnet ef migrations remove `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext
# 回滚到指定迁移(会执行 Down,生产慎用)
dotnet ef database update <目标迁移名> `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext
```
使用本地工具时,将 `dotnet ef` 换成 `.\.tools\dotnet-ef`
---
## 5. 启动时发生了什么
**完整说明(顺序、配置、SchemaMode、开发注意)见:**
**[MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)**
摘要:`EnsurePlatformDatabaseAsync``MigrateMiGuDbAsync``Database:SchemaMode``EnsureCreated``Migrate`;之后按需跑 `IDataMigrator`
**Migrate 基线:** 已有表、无 History 时,会把**全部** pending Migration 写入 `__EFMigrationsHistory`(假定 EnsureCreated 库已对齐模型 tip)。发版前若开发期改过模型,须先 `migrations add` 再切 `Migrate`
---
## 6. Schema 变更 vs 数据修补
| 需求 | 做法 |
|------|------|
| 新表/新列/索引/改列类型 | `migrations add` → 提交迁移文件 |
| 刷旧枚举字符串、回填列 | 新增 `IDataMigrator`,注册到 `AddMiGuDataMigrators` |
| 开发机整库清空重来 | 删 `data/platform.db*` 后启动(等同空库 Migrate);**不要**在生产用 EnsureDeleted |
注意:带 `HasConversion` 的枚举列,用 `ExecuteUpdate` + 原始字符串比较可能触发转换异常;旧值刷库需绕过 converter(参见 `LegacyStatusNormalizationMigrator`)。
---
## 7. 协作与发版
1. **迁移文件必须入库**(含 Designer、Snapshot
2. 多人同时改模型易冲突 Snapshot:保留一方迁移,另一方 `remove` 后基于最新代码重新 `add`
3. 已合并到主分支并可能已应用到共享库的迁移:**不要** `migrations remove` 或改写历史 `Up()`
4. 发版包随程序集带上 Migration;现场首次升级靠启动 Migrate(或预执行 `migrations script`
---
## 8. 其他 Provider(预留)
`IDbProviderSetup` 已预留 MySql / Npgsql / SqlServer。首轮只有 Sqlite 迁移套。
将来为企业库生成独立套时:
```powershell
# 示例:输出到 Migrations/MySqlnamespace 同步修改
dotnet ef migrations add InitialPlatform `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--output-dir Migrations/MySql `
--namespace MiGu.DB.Migrations.MySql `
--context MiGuDbContext
```
并确保对应 Provider 的 `MigrationsAssembly` / 运行时能发现该套迁移(按需拆程序集或过滤命名空间)。
---
## 9. 常见问题
| 现象 | 处理 |
|------|------|
| `dotnet ef` 找不到 | 安装 8.0.10 工具,或用 `.\.tools\dotnet-ef` |
| Design-time 连错库 | 检查 `MiGuDbContextFactory`(默认 `platform.db`);运行时以 Server 配置为准 |
| Snapshot 生成到奇怪目录 | 移回 `Migrations/Sqlite/` |
| 旧库启动重复建表失败 | 确认基线逻辑是否写入 History;备份后必要时手工插入 Initial 行 |
| 改完实体 `add` 生成空迁移 | 模型无差异或未编译;先 `dotnet build` |
| 想撤销未提交的迁移 | `migrations remove`(确认未被他人/现场应用) |
---
## 10. 检查清单(每次提 PR)
- [ ] `dotnet build` 通过
- [ ] 新迁移仅含预期 DDL
- [ ] 文件在 `Migrations/Sqlite/`Snapshot 路径正确
- [ ] 本地启动一次,确认 Migrate 成功
- [ ] 若有数据刷库,已加幂等 `IDataMigrator` 并注明 Order
- [ ] 未改写已发布的历史 Migration
+30
View File
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MiGu.DB</RootNamespace>
<AssemblyName>MiGu.DB</AssemblyName>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" 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>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
</ItemGroup>
</Project>
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,754 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MiGu.DB.Migrations.Sqlite
{
/// <summary>
/// Sqlite 初始 Schema(平台库全量表)。旧 EnsureCreated 库启动时会先基线本迁移名,再应用后续增量。
/// </summary>
public partial class InitialPlatform : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "simple_fields",
columns: table => new
{
id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
car_type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
field_type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
key = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
value = table.Column<string>(type: "TEXT", nullable: false),
data_type = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
chinese = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
english = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
other = table.Column<string>(type: "TEXT", maxLength: 512, nullable: false),
is_default = table.Column<bool>(type: "INTEGER", nullable: false),
create_time = table.Column<string>(type: "TEXT", maxLength: 19, nullable: false),
update_time = table.Column<string>(type: "TEXT", maxLength: 19, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_simple_fields", x => x.id);
});
migrationBuilder.CreateTable(
name: "user_dashboard_shortcuts",
columns: table => new
{
user_id = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
scope = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
keys_json = table.Column<string>(type: "text", nullable: false),
updated_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_dashboard_shortcuts", x => new { x.user_id, x.scope });
});
migrationBuilder.CreateTable(
name: "wms_areas",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
WarehouseId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
LayoutMode = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
State = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_areas", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_container_location_history",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
FromLocationType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
FromLocationId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
ToLocationType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ToLocationId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
RelationId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
BeforeJson = table.Column<string>(type: "text", nullable: false),
AfterJson = table.Column<string>(type: "text", nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
Source = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_container_location_history", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_container_locations",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
LocationType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
LocationId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
StorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
LocationCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
LocationName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
EnteredAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_container_locations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_container_material_history",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
QuantityDelta = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
RelationId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
BeforeJson = table.Column<string>(type: "text", nullable: false),
AfterJson = table.Column<string>(type: "text", nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
Source = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_container_material_history", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_container_materials",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
BatchNo = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
SerialNo = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
BoundAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
LoadedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UnloadedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_container_materials", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_containers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
AreaId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
ContainerType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Barcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Length = table.Column<double>(type: "REAL", nullable: false),
Width = table.Column<double>(type: "REAL", nullable: false),
Height = table.Column<double>(type: "REAL", nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_containers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_material_types",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Spec = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Unit = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Category = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
BarcodePrefix = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_material_types", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_materials",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
TypeCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Barcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Spec = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Unit = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Category = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
LifecycleStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
UnboundAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_materials", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_stock_events",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
EventType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
MaterialCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
MaterialName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
MaterialBarcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
MaterialTypeCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
ContainerCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
ContainerName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
StorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
StorageCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
StorageName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
AreaCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
FromStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
FromStorageCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
FromStorageName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
ToStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
ToStorageCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
ToStorageName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
RefType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
RefId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
RefCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_stock_events", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_storages",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
AreaId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
StorageType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
LocationKind = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ColumnNo = table.Column<int>(type: "INTEGER", nullable: false),
LevelNo = table.Column<int>(type: "INTEGER", nullable: false),
DepthNo = table.Column<int>(type: "INTEGER", nullable: false),
SiteId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
SiteCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Barcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Capacity = table.Column<int>(type: "INTEGER", nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Usage = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false),
ZoneCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
AllowInbound = table.Column<bool>(type: "INTEGER", nullable: false),
AllowOutbound = table.Column<bool>(type: "INTEGER", nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_storages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_transport_reservations",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
TaskId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
SourceStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
TargetStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ExpiresAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_transport_reservations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_transport_rules",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
TriggerType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false),
SourceSelectorJson = table.Column<string>(type: "text", nullable: false),
TargetSelectorJson = table.Column<string>(type: "text", nullable: false),
TaskOptionsJson = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_transport_rules", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_transport_task_history",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
TaskId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
FromStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ToStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
ErrorMessage = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
SnapshotJson = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_transport_task_history", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_transport_tasks",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
BusinessType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
RuleId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
SourceStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
TargetStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: true),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
DispatchMissionId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
DeliveryId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
DispatchStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
SnapshotJson = table.Column<string>(type: "text", nullable: false),
ErrorMessage = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
TaskPriority = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_transport_tasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_warehouses",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_warehouses", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_simple_fields_car_type_field_type_key",
table: "simple_fields",
columns: new[] { "car_type", "field_type", "key" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_areas_Code",
table: "wms_areas",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_areas_WarehouseId",
table: "wms_areas",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_wms_container_location_history_ContainerId",
table: "wms_container_location_history",
column: "ContainerId");
migrationBuilder.CreateIndex(
name: "IX_wms_container_location_history_EventType",
table: "wms_container_location_history",
column: "EventType");
migrationBuilder.CreateIndex(
name: "IX_wms_container_location_history_OperatedAt",
table: "wms_container_location_history",
column: "OperatedAt");
migrationBuilder.CreateIndex(
name: "IX_wms_container_locations_ContainerId",
table: "wms_container_locations",
column: "ContainerId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_container_locations_LocationType_LocationId",
table: "wms_container_locations",
columns: new[] { "LocationType", "LocationId" });
migrationBuilder.CreateIndex(
name: "IX_wms_container_locations_StorageId",
table: "wms_container_locations",
column: "StorageId");
migrationBuilder.CreateIndex(
name: "IX_wms_container_material_history_ContainerId",
table: "wms_container_material_history",
column: "ContainerId");
migrationBuilder.CreateIndex(
name: "IX_wms_container_material_history_EventType",
table: "wms_container_material_history",
column: "EventType");
migrationBuilder.CreateIndex(
name: "IX_wms_container_material_history_OperatedAt",
table: "wms_container_material_history",
column: "OperatedAt");
migrationBuilder.CreateIndex(
name: "IX_wms_container_materials_ContainerId",
table: "wms_container_materials",
column: "ContainerId");
migrationBuilder.CreateIndex(
name: "IX_wms_container_materials_MaterialId",
table: "wms_container_materials",
column: "MaterialId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_containers_Code",
table: "wms_containers",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_material_types_Code",
table: "wms_material_types",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_materials_Barcode",
table: "wms_materials",
column: "Barcode");
migrationBuilder.CreateIndex(
name: "IX_wms_materials_Code",
table: "wms_materials",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_materials_LifecycleStatus_UpdatedAt",
table: "wms_materials",
columns: new[] { "LifecycleStatus", "UpdatedAt" });
migrationBuilder.CreateIndex(
name: "IX_wms_materials_TypeCode",
table: "wms_materials",
column: "TypeCode");
migrationBuilder.CreateIndex(
name: "IX_wms_stock_events_ContainerId",
table: "wms_stock_events",
column: "ContainerId");
migrationBuilder.CreateIndex(
name: "IX_wms_stock_events_EventType",
table: "wms_stock_events",
column: "EventType");
migrationBuilder.CreateIndex(
name: "IX_wms_stock_events_MaterialId",
table: "wms_stock_events",
column: "MaterialId");
migrationBuilder.CreateIndex(
name: "IX_wms_stock_events_OperatedAt",
table: "wms_stock_events",
column: "OperatedAt");
migrationBuilder.CreateIndex(
name: "IX_wms_storages_AreaId",
table: "wms_storages",
column: "AreaId");
migrationBuilder.CreateIndex(
name: "IX_wms_storages_Barcode",
table: "wms_storages",
column: "Barcode");
migrationBuilder.CreateIndex(
name: "IX_wms_storages_Code",
table: "wms_storages",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_transport_reservations_ContainerId_Status",
table: "wms_transport_reservations",
columns: new[] { "ContainerId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_wms_transport_reservations_TargetStorageId_Status",
table: "wms_transport_reservations",
columns: new[] { "TargetStorageId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_wms_transport_rules_Code",
table: "wms_transport_rules",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_transport_rules_TriggerType_Enabled_Priority",
table: "wms_transport_rules",
columns: new[] { "TriggerType", "Enabled", "Priority" });
migrationBuilder.CreateIndex(
name: "IX_wms_transport_task_history_OperatedAt",
table: "wms_transport_task_history",
column: "OperatedAt");
migrationBuilder.CreateIndex(
name: "IX_wms_transport_task_history_TaskId",
table: "wms_transport_task_history",
column: "TaskId");
migrationBuilder.CreateIndex(
name: "IX_wms_transport_tasks_ContainerId",
table: "wms_transport_tasks",
column: "ContainerId");
migrationBuilder.CreateIndex(
name: "IX_wms_transport_tasks_Status",
table: "wms_transport_tasks",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_wms_transport_tasks_TargetStorageId",
table: "wms_transport_tasks",
column: "TargetStorageId");
migrationBuilder.CreateIndex(
name: "IX_wms_warehouses_Code",
table: "wms_warehouses",
column: "Code",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "simple_fields");
migrationBuilder.DropTable(
name: "user_dashboard_shortcuts");
migrationBuilder.DropTable(
name: "wms_areas");
migrationBuilder.DropTable(
name: "wms_container_location_history");
migrationBuilder.DropTable(
name: "wms_container_locations");
migrationBuilder.DropTable(
name: "wms_container_material_history");
migrationBuilder.DropTable(
name: "wms_container_materials");
migrationBuilder.DropTable(
name: "wms_containers");
migrationBuilder.DropTable(
name: "wms_material_types");
migrationBuilder.DropTable(
name: "wms_materials");
migrationBuilder.DropTable(
name: "wms_stock_events");
migrationBuilder.DropTable(
name: "wms_storages");
migrationBuilder.DropTable(
name: "wms_transport_reservations");
migrationBuilder.DropTable(
name: "wms_transport_rules");
migrationBuilder.DropTable(
name: "wms_transport_task_history");
migrationBuilder.DropTable(
name: "wms_transport_tasks");
migrationBuilder.DropTable(
name: "wms_warehouses");
}
}
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
# MiGu.DB
迷毂平台持久化框架(EF Core 8)。由 `MiGu.Server` 引用;本工程不依赖 ASP.NET。
## 分层
| 目录 | 职责 |
|------|------|
| `Abstractions/` | 对外契约:能力接口、仓储/UoW、Provider、Module、Actor、异常 |
| `Kernel/` | 框架实现:实体基类族、MiGuDbContext、约定/Interceptor、仓储、Hosting |
| `Domains/` | 业务实体、枚举、辅助类与 `IEntityTypeConfiguration` |
| `Migrations/Sqlite/` | Schema 版本化来源(发版用;开发可用 EnsureCreated 不跑迁移) |
## 使用
`MiGu.Server` 调用:
```csharp
builder.Services.AddPlatformPersistence(builder.Configuration); // 内部 AddMiGuDb
await app.Services.EnsurePlatformDatabaseAsync(); // 内部 MigrateMiGuDbAsync
```
**启动流程、SchemaMode、开发/发版配置说明见 [MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)。**
配置键(`Database:*` / `ConnectionStrings:Platform`)由 Server 的 `appsettings*.json` 提供;本库通过 `AddMiGuDb(IConfiguration)` 读取。
## 实体与枚举
- 业务状态字段为 **enum**,约定自动 `HasConversion<string>()`(严格 1:1,不做读侧归一)。
- 旧库值(如 `Available`/`Idle`)由 `LegacyStatusNormalizationMigrator` 一次性刷成规范名。
- `Version` 为乐观并发令牌;软删走全局 `HasQueryFilter`
- 复数辅助类(`StorageStatuses``LocationKinds`…)负责 API 字符串解析与集合校验。
## 扩展
- **新 Provider**:实现 `IDbProviderSetup` + `Migrations/{Name}/`
- **新领域**`Domains/Xxx` 实体 + Configuration;可选 `IEntityModule`
- **数据修补**:实现 `IDataMigrator`(优先 LINQ;枚举旧值刷库等特例可定点 raw UPDATE)
## 生成 MigrationSqlite
完整步骤、命令说明与排错见 **[MIGRATIONS.md](./MIGRATIONS.md)**。
快速命令:
```powershell
dotnet ef migrations add <Name> `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--output-dir Migrations/Sqlite `
--namespace MiGu.DB.Migrations.Sqlite `
--context MiGuDbContext
```
生成后请确认 `MiGuDbContextModelSnapshot.cs` 落在 `Migrations/Sqlite/`
开发期使用 `EnsureCreated` 时仍可保留/继续提交 Migration 文件,启动不会应用它们,直到 `SchemaMode` 改回 `Migrate`(见 Server README)。
Binary file not shown.
+6
View File
@@ -3,6 +3,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 17
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiGu.Server", "MiGu.Server\MiGu.Server.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiGu.Server", "MiGu.Server\MiGu.Server.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiGu.DB", "MiGu.DB\MiGu.DB.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -13,5 +15,9 @@ Global
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+20 -3
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using MiGu.DB.Abstractions.Exceptions;
using MiGu.Server.Wms; using MiGu.Server.Wms;
namespace MiGu.Server.Controllers; namespace MiGu.Server.Controllers;
@@ -276,15 +277,31 @@ public sealed class WmsExceptionFilter : IExceptionFilter
{ {
public void OnException(ExceptionContext context) public void OnException(ExceptionContext context)
{ {
if (context.Exception is not InvalidOperationException ex) return; switch (context.Exception)
context.Result = new BadRequestObjectResult(new { message = ex.Message }); {
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; context.ExceptionHandled = true;
} }
} }
public sealed class TransportRequestQuery 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 Guid? RuleId { get; set; }
public string? RequestSiteId { get; set; } public string? RequestSiteId { get; set; }
public Guid? MaterialId { get; set; } public Guid? MaterialId { 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="Yarp.ReverseProxy" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" /> <PrivateAssets>all</PrivateAssets>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.10" /> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" /> </PackageReference>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" /> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MiGu.DB\MiGu.DB.csproj" />
</ItemGroup> </ItemGroup>
<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 MiGu.DB.Kernel.Hosting;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Configuration;
using Microsoft.Data.Sqlite; using Microsoft.Extensions.DependencyInjection;
using MiGu.Server.Wms;
using MiGu.Server.SimpleFields;
namespace MiGu.Server.Persistence; namespace MiGu.Server.Persistence;
/// <summary>兼容入口:转调 MiGu.DB Hosting,并注册本进程业务服务。</summary>
public static class PlatformPersistence public static class PlatformPersistence
{ {
public static IServiceCollection AddPlatformPersistence(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddPlatformPersistence(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddDbContext<PlatformDbContext>((sp, options) => services.AddMiGuDb(configuration);
{
var env = sp.GetRequiredService<IWebHostEnvironment>();
var provider = configuration["Database:Provider"] ?? "sqlite";
var connection = ResolveConnectionString(configuration, env, provider);
switch (provider.Trim().ToLowerInvariant()) services.AddScoped<Wms.WmsReferenceValidator>();
{ services.AddScoped<Wms.WmsService>();
case "sqlite": services.AddScoped<Wms.WmsTransportRuleService>();
options.UseSqlite(connection); services.AddScoped<Wms.WmsTransportPlanner>();
break; services.AddScoped<Wms.WmsTransportTaskService>();
case "mysql": services.AddScoped<Wms.IWmsDispatchAdapter, Wms.NoopWmsDispatchAdapter>();
options.UseMySql(connection, ServerVersion.AutoDetect(connection)); services.AddScoped<SimpleFields.SimpleFieldService>();
break; services.AddScoped<Dashboard.DashboardShortcutService>();
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>();
return services; return services;
} }
public static async Task EnsurePlatformDatabaseAsync(this IServiceProvider services) /// <summary>启动建库/迁移入口(内部即 MigrateMiGuDbAsyncMigrate + 基线 + DataMigrator)。</summary>
{ public static Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
using var scope = services.CreateScope(); => services.MigrateMiGuDbAsync();
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();
}
} }
+1
View File
@@ -303,6 +303,7 @@ app.UseStaticFiles();
// 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。 // 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。
app.UseAuthentication(); app.UseAuthentication();
app.UseMiddleware<HttpActorContextMiddleware>();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
+85 -1
View File
@@ -97,6 +97,88 @@ $env:SimpleLite__Enabled = "true"
> 如果只想跑 MiGu.Server 单进程调试(不拉 SimpleLite),把 `appsettings.json:SimpleLite.Enabled` 改为 `false` 即可。 > 如果只想跑 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` - 健康检查:`/api/health`
- Swagger:开发环境下 `/swagger` - 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 视觉规范) - [ARCHITECTURE.md](ARCHITECTURE.md) — v1.6 总体架构(§4 进程拓扑、§6.3 YARP 配置、§9 配置中心、§10 交互序列、§17 视觉规范)
- [(见 Simple 仓库)frontends/README.md]((见 Simple 仓库)frontends/README.md) — 前端启动与联调说明(含「迷毂」品牌与紫色主题约定) - [(见 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; 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( public sealed record SimpleFieldRequest(
Guid? Id, Guid? Id,
string CarType, string CarType,
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MiGu.Server.Persistence; using MiGu.Server.Persistence;
using MiGu.DB.Domains.SimpleFields;
namespace MiGu.Server.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; namespace MiGu.Server.Wms;
public abstract class WarehouseHistoryBase // API / 读模型 DTO(留在 Server,不进 MiGu.DB)。请求字段中的状态仍为 string,由 Service 解析为枚举后写入实体。
{
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; } = "{}";
}
public sealed class Warehouse : EntityBase public sealed record InventoryMaterialRow(
{ Guid MaterialId, string MaterialCode, string MaterialName, string MaterialBarcode, string MaterialTypeCode,
[MaxLength(64)] public string Code { get; set; } = ""; Guid ContainerId, string ContainerCode, string ContainerName,
[MaxLength(128)] public string Name { get; set; } = ""; Guid? StorageId, string StorageCode, string StorageName,
[MaxLength(64)] public string Type { get; set; } = "Default"; Guid? AreaId, string AreaCode, string AreaName,
public bool Enabled { get; set; } = true; string LocationType, DateTimeOffset BoundAt);
public int SortOrder { get; set; }
}
public sealed class WarehouseArea : EntityBase public sealed record ContainerLocationSnapshot(
{ Guid Id, Guid ContainerId, string LocationType, string LocationId, string LocationCode, string LocationName,
public Guid WarehouseId { get; set; } string Status, DateTimeOffset EnteredAt, long Version);
[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 class Storage : EntityBase public sealed record ContainerMaterialSnapshot(
{ Guid Id, Guid ContainerId, Guid MaterialId, decimal Quantity, string BatchNo, string SerialNo, string Status,
public Guid AreaId { get; set; } DateTimeOffset BoundAt, DateTimeOffset LoadedAt, DateTimeOffset? UnloadedAt, long Version);
[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 class Container : EntityBase public abstract record CommonRequest(Guid? Id, long? Version, bool IsLock, string Remark, string Extend);
{
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 sealed class MaterialType : EntityBase public sealed record MasterDataRequest(
{ Guid? Id, long? Version, string Code, string Name, string Type, string Status, bool Enabled, int SortOrder,
[MaxLength(64)] public string Code { get; set; } = ""; bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
[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 class Material : EntityBase public sealed record AreaRequest(
{ Guid? Id, long? Version, Guid? WarehouseId, string Code, string Name, string Type, string LayoutMode, string State,
[MaxLength(64)] public string Code { get; set; } = ""; bool Enabled, int SortOrder, bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
[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 class ContainerLocation : EntityBase public sealed record StorageRequest(
{ Guid? Id, long? Version, Guid AreaId, string Code, string Name, string StorageType, string LocationKind,
public Guid ContainerId { get; set; } int ColumnNo, int LevelNo, int DepthNo, string SiteId, string SiteCode, string Barcode, int Capacity,
[MaxLength(32)] public string LocationType { get; set; } = ContainerLocationTypes.Storage; string Status, string Usage, int Priority, string ZoneCode, bool AllowInbound, bool AllowOutbound, bool Enabled,
[MaxLength(64)] public string LocationId { get; set; } = ""; bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
[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; }
}
/// <summary>容器-物料绑定(无数量语义;Quantity 列仅兼容旧库,固定为 1)。</summary> public sealed record GenerateBinsRequest(int ColumnFrom, int ColumnTo, int LevelFrom, int LevelTo, int DepthFrom, int DepthTo, string? CodePattern);
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 class StockEvent public sealed record ContainerRequest(
{ Guid? Id, long? Version, Guid? AreaId, string Code, string Name, string Type, string Status, string Barcode,
public Guid Id { get; set; } = Guid.NewGuid(); double Length, double Width, double Height, bool Enabled, bool IsLock, string Remark, string Extend)
[MaxLength(32)] public string EventType { get; set; } = ""; : CommonRequest(Id, Version, IsLock, Remark, Extend);
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 class ContainerLocationHistory : WarehouseHistoryBase public sealed record MaterialTypeRequest(
{ Guid? Id, long? Version, string Code, string Name, string Spec, string Unit, string Category, string BarcodePrefix,
[MaxLength(32)] public string FromLocationType { get; set; } = ""; bool Enabled, bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
[MaxLength(64)] public string FromLocationId { get; set; } = "";
[MaxLength(32)] public string ToLocationType { get; set; } = "";
[MaxLength(64)] public string ToLocationId { get; set; } = "";
}
public sealed class ContainerMaterialHistory : WarehouseHistoryBase public sealed record MaterialRequest(
{ Guid? Id, long? Version, string Code, string Name, string TypeCode, string Barcode, string Spec, string Unit,
public Guid? MaterialId { get; set; } string Category, string LifecycleStatus, bool Enabled, bool IsLock, string Remark, string Extend)
public decimal QuantityDelta { get; set; } : CommonRequest(Id, Version, IsLock, Remark, Extend);
}
public static class AreaLayoutModes public sealed record ContainerLocationRequest(
{ Guid? Id, long? Version, Guid ContainerId, string LocationType, string LocationId, string Status,
public const string Flat = "Flat"; DateTimeOffset? EnteredAt, string Source, string Reason, bool IsLock, string Remark, string Extend)
public const string Grid = "Grid"; : CommonRequest(Id, Version, IsLock, Remark, Extend);
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Flat, Grid };
}
public static class LocationKinds public sealed record BindMaterialRequest(
{ Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, string Source, string Reason,
public const string Grid = "Grid"; bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public const string Station = "Station";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Grid, Station };
}
public static class ContainerLocationTypes public sealed record ContainerMaterialRequest(
{ Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, decimal Quantity, string BatchNo, string SerialNo,
public const string Storage = "Storage"; string Status, DateTimeOffset? LoadedAt, DateTimeOffset? UnloadedAt, string Source, string Reason,
public const string Car = "Car"; bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
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 = "默认仓库";
}
+2 -2
View File
@@ -50,12 +50,12 @@ public sealed class WmsReferenceValidator
public async Task<(string Code, string Name)> ResolveLocationSnapshotAsync(string locationType, string locationId) public async Task<(string Code, string Name)> ResolveLocationSnapshotAsync(string locationType, string locationId)
{ {
if (!ContainerLocationTypes.All.Contains(locationType)) if (!ContainerLocationTypes.IsDefined(locationType))
throw new InvalidOperationException("位置类型无效"); throw new InvalidOperationException("位置类型无效");
if (string.IsNullOrWhiteSpace(locationId)) if (string.IsNullOrWhiteSpace(locationId))
throw new InvalidOperationException("位置 ID 不能为空"); 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 格式无效"); if (!Guid.TryParse(locationId, out var id)) throw new InvalidOperationException("库位 ID 格式无效");
var s = await _db.Storages.FirstOrDefaultAsync(x => x.Id == 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.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MiGu.DB.Abstractions.Entities;
using MiGu.DB.Abstractions.Persistence;
using MiGu.Server.Persistence; using MiGu.Server.Persistence;
namespace MiGu.Server.Wms; namespace MiGu.Server.Wms;
@@ -8,12 +11,20 @@ namespace MiGu.Server.Wms;
public sealed class WmsService public sealed class WmsService
{ {
private readonly PlatformDbContext _db; private readonly PlatformDbContext _db;
private readonly IUnitOfWork _uow;
private readonly IServiceProvider _services;
private readonly WmsReferenceValidator _refs; private readonly WmsReferenceValidator _refs;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web); 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; _db = db;
_uow = uow;
_services = services;
_refs = refs; _refs = refs;
} }
@@ -31,7 +42,10 @@ public sealed class WmsService
{ {
var query = _db.Storages.AsNoTracking().AsQueryable(); var query = _db.Storages.AsNoTracking().AsQueryable();
if (areaId.HasValue) query = query.Where(x => x.AreaId == areaId.Value); 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(); 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) public Task<List<Material>> Materials(string? q = null, string? lifecycle = null, bool? onlyUnbound = null, bool? onlyBound = null)
{ {
var query = _db.Materials.AsNoTracking().AsQueryable(); 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); query = query.Where(x => x.LifecycleStatus == life);
if (onlyUnbound == true || onlyBound == true) if (onlyUnbound == true || onlyBound == true)
{ {
@@ -58,7 +72,11 @@ public sealed class WmsService
public Task<List<ContainerLocation>> ContainerLocations(string? locationType = null, string? q = null) public Task<List<ContainerLocation>> ContainerLocations(string? locationType = null, string? q = null)
{ {
var query = _db.ContainerLocations.AsNoTracking().OrderBy(x => x.ContainerId).AsQueryable(); 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(); return FilterByKeyword(query, q).ToListAsync();
} }
@@ -69,61 +87,71 @@ public sealed class WmsService
return FilterByKeyword(query.OrderBy(x => x.ContainerId), q).ToListAsync(); 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) public async Task<List<InventoryMaterialRow>> InventoryMaterials(Guid? areaId = null, Guid? storageId = null, string? q = null)
{ {
var binds = await _db.ContainerMaterials.AsNoTracking().ToListAsync(); var query =
var materials = await _db.Materials.AsNoTracking().ToDictionaryAsync(x => x.Id); from b in _db.ContainerMaterials.AsNoTracking()
var locations = await _db.ContainerLocations.AsNoTracking().ToDictionaryAsync(x => x.ContainerId); join mat in _db.Materials.AsNoTracking() on b.MaterialId equals mat.Id
var storages = await _db.Storages.AsNoTracking().ToDictionaryAsync(x => x.Id); from ctn in _db.Containers.AsNoTracking().Where(c => c.Id == b.ContainerId).DefaultIfEmpty()
var areas = await _db.WarehouseAreas.AsNoTracking().ToDictionaryAsync(x => x.Id); from loc in _db.ContainerLocations.AsNoTracking().Where(l => l.ContainerId == b.ContainerId).DefaultIfEmpty()
var containers = await _db.Containers.AsNoTracking().ToDictionaryAsync(x => x.Id); 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>(); if (storageId.HasValue)
foreach (var b in binds) 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; var s = q.Trim();
locations.TryGetValue(b.ContainerId, out var loc); query = query.Where(x =>
Storage? storage = null; x.mat.Code.Contains(s) || x.mat.Name.Contains(s) ||
WarehouseArea? area = null; (x.ctn != null && (x.ctn.Code.Contains(s) || x.ctn.Name.Contains(s))) ||
if (loc != null && loc.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(loc.LocationId, out var sid)) (x.st != null && x.st.Code.Contains(s)));
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));
} }
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) public async Task<List<StockEvent>> StockEvents(string? eventType = null, Guid? materialId = null, Guid? containerId = null)
{ {
var query = _db.StockEvents.AsNoTracking().AsQueryable(); 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 (materialId.HasValue) query = query.Where(x => x.MaterialId == materialId.Value);
if (containerId.HasValue) query = query.Where(x => x.ContainerId == containerId.Value); if (containerId.HasValue) query = query.Where(x => x.ContainerId == containerId.Value);
var rows = await query.ToListAsync(); return await query.OrderByDescending(x => x.OperatedAt).Take(500).ToListAsync();
return rows.OrderByDescending(x => x.OperatedAt).Take(500).ToList();
} }
public async Task<Warehouse> SaveWarehouse(MasterDataRequest req, string actor) public async Task<Warehouse> SaveWarehouse(MasterDataRequest req, string actor)
{ {
Warehouse entity; 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 else
{ {
entity = new Warehouse(); entity = new Warehouse();
@@ -137,7 +165,7 @@ public sealed class WmsService
entity.Enabled = req.Enabled; entity.Enabled = req.Enabled;
entity.SortOrder = req.SortOrder; entity.SortOrder = req.SortOrder;
ApplyCommon(entity, req, actor); ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
return entity; return entity;
} }
@@ -150,7 +178,7 @@ public sealed class WmsService
await _refs.EnsureWarehouseAsync(warehouseId); await _refs.EnsureWarehouseAsync(warehouseId);
WarehouseArea entity; 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 else
{ {
entity = new WarehouseArea(); entity = new WarehouseArea();
@@ -162,24 +190,24 @@ public sealed class WmsService
entity.Code = req.Code.Trim(); entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim(); entity.Name = req.Name.Trim();
entity.Type = req.Type.TrimOr("Storage"); 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.State = req.State.TrimOr("Default");
entity.Enabled = req.Enabled; entity.Enabled = req.Enabled;
entity.SortOrder = req.SortOrder; entity.SortOrder = req.SortOrder;
ApplyCommon(entity, req, actor); ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
return entity; return entity;
} }
public async Task<Storage> SaveStorage(StorageRequest req, string actor) public async Task<Storage> SaveStorage(StorageRequest req, string actor)
{ {
await _refs.EnsureAreaAsync(req.AreaId); 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) if (kind == LocationKinds.Grid)
await EnsureGridCoordUnique(req.AreaId, req.ColumnNo, req.LevelNo, req.DepthNo, req.Id); await EnsureGridCoordUnique(req.AreaId, req.ColumnNo, req.LevelNo, req.DepthNo, req.Id);
Storage entity; 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 else
{ {
entity = new Storage(); entity = new Storage();
@@ -199,7 +227,7 @@ public sealed class WmsService
entity.SiteCode = req.SiteCode.TrimOr(entity.SiteId); entity.SiteCode = req.SiteCode.TrimOr(entity.SiteId);
entity.Barcode = req.Barcode.TrimOr(""); entity.Barcode = req.Barcode.TrimOr("");
entity.Capacity = 1; 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) if (status == StorageStatuses.Disabled || req.Enabled == false)
entity.Status = req.Enabled ? status : StorageStatuses.Disabled; entity.Status = req.Enabled ? status : StorageStatuses.Disabled;
else if (!string.IsNullOrWhiteSpace(req.Status) && StorageStatuses.All.Contains(status)) else if (!string.IsNullOrWhiteSpace(req.Status) && StorageStatuses.All.Contains(status))
@@ -211,7 +239,7 @@ public sealed class WmsService
entity.AllowOutbound = req.AllowOutbound; entity.AllowOutbound = req.AllowOutbound;
entity.Enabled = req.Enabled; entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor); ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
if (entity.Status != StorageStatuses.Disabled) if (entity.Status != StorageStatuses.Disabled)
await SyncOccupancyStatus(storageId: entity.Id); await SyncOccupancyStatus(storageId: entity.Id);
return entity; return entity;
@@ -262,28 +290,28 @@ public sealed class WmsService
_db.Storages.Add(entity); _db.Storages.Add(entity);
created++; created++;
} }
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
return created; return created;
} }
public async Task<Storage> SetStorageLock(Guid id, bool isLock, long? version, string actor) 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.IsLock = isLock;
entity.UpdatedBy = actor; entity.UpdatedBy = actor;
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
return entity; return entity;
} }
public async Task<Storage> SetStorageEnabled(Guid id, bool enabled, long? version, string actor) 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.Enabled = enabled;
entity.Status = enabled entity.Status = enabled
? (entity.Status == StorageStatuses.Disabled ? StorageStatuses.Empty : entity.Status) ? (entity.Status == StorageStatuses.Disabled ? StorageStatuses.Empty : entity.Status)
: StorageStatuses.Disabled; : StorageStatuses.Disabled;
entity.UpdatedBy = actor; entity.UpdatedBy = actor;
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
if (enabled) await SyncOccupancyStatus(storageId: id); if (enabled) await SyncOccupancyStatus(storageId: id);
return entity; return entity;
} }
@@ -291,7 +319,7 @@ public sealed class WmsService
public async Task<Container> SaveContainer(ContainerRequest req, string actor) public async Task<Container> SaveContainer(ContainerRequest req, string actor)
{ {
Container entity; 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 else
{ {
entity = new Container(); entity = new Container();
@@ -303,15 +331,14 @@ public sealed class WmsService
entity.Code = req.Code.Trim(); entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim(); entity.Name = req.Name.Trim();
entity.ContainerType = req.Type.TrimOr("Box"); entity.ContainerType = req.Type.TrimOr("Box");
var status = req.Status.TrimOr(ContainerStatuses.EmptyMaterial); entity.Status = ContainerStatuses.ParseOr(req.Status);
entity.Status = ContainerStatuses.All.Contains(status) ? status : ContainerStatuses.EmptyMaterial;
entity.Barcode = req.Barcode.TrimOr(""); entity.Barcode = req.Barcode.TrimOr("");
entity.Length = req.Length; entity.Length = req.Length;
entity.Width = req.Width; entity.Width = req.Width;
entity.Height = req.Height; entity.Height = req.Height;
entity.Enabled = req.Enabled; entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor); ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: entity.Id); await SyncOccupancyStatus(containerId: entity.Id);
return entity; return entity;
} }
@@ -319,7 +346,7 @@ public sealed class WmsService
public async Task<MaterialType> SaveMaterialType(MaterialTypeRequest req, string actor) public async Task<MaterialType> SaveMaterialType(MaterialTypeRequest req, string actor)
{ {
MaterialType entity; 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 else
{ {
entity = new MaterialType(); entity = new MaterialType();
@@ -335,7 +362,7 @@ public sealed class WmsService
entity.BarcodePrefix = req.BarcodePrefix.TrimOr(""); entity.BarcodePrefix = req.BarcodePrefix.TrimOr("");
entity.Enabled = req.Enabled; entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor); ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
return entity; return entity;
} }
@@ -344,7 +371,7 @@ public sealed class WmsService
if (!string.IsNullOrWhiteSpace(req.TypeCode)) if (!string.IsNullOrWhiteSpace(req.TypeCode))
await _refs.EnsureMaterialTypeCodeAsync(req.TypeCode); await _refs.EnsureMaterialTypeCodeAsync(req.TypeCode);
Material entity; 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 else
{ {
entity = new Material(); entity = new Material();
@@ -361,23 +388,22 @@ public sealed class WmsService
entity.Spec = req.Spec.TrimOr(""); entity.Spec = req.Spec.TrimOr("");
entity.Unit = req.Unit.TrimOr("pcs"); entity.Unit = req.Unit.TrimOr("pcs");
entity.Category = req.Category.TrimOr(""); entity.Category = req.Category.TrimOr("");
var life = req.LifecycleStatus.TrimOr(MaterialLifecycles.Active); entity.LifecycleStatus = MaterialLifecycles.ParseOr(req.LifecycleStatus);
entity.LifecycleStatus = MaterialLifecycles.All.Contains(life) ? life : MaterialLifecycles.Active;
entity.Enabled = req.Enabled; entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor); ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
return entity; return entity;
} }
public async Task<Material> ArchiveMaterial(Guid id, long? version, string actor) 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)) if (await _db.ContainerMaterials.AnyAsync(x => x.MaterialId == id))
throw new InvalidOperationException("物料仍在绑定中,不能归档"); throw new InvalidOperationException("物料仍在绑定中,不能归档");
entity.LifecycleStatus = MaterialLifecycles.Archived; entity.LifecycleStatus = MaterialLifecycles.Archived;
entity.UnboundAt ??= DateTimeOffset.UtcNow; entity.UnboundAt ??= DateTimeOffset.UtcNow;
entity.UpdatedBy = actor; entity.UpdatedBy = actor;
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
return entity; return entity;
} }
@@ -396,13 +422,12 @@ public sealed class WmsService
entity.DeletedBy = actor; entity.DeletedBy = actor;
entity.UpdatedBy = actor; entity.UpdatedBy = actor;
} }
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
} }
public async Task DeleteEntity<T>(Guid id, long? version, string actor) where T : EntityBase public async Task DeleteEntity<T>(Guid id, long? version, string actor) where T : EntityBase
{ {
var set = _db.Set<T>(); var entity = await FindEditable<T>(id, version);
var entity = await FindEditable(set, id, version);
if (typeof(T) == typeof(WarehouseArea)) if (typeof(T) == typeof(WarehouseArea))
{ {
if (await _db.Storages.AnyAsync(x => x.AreaId == id)) if (await _db.Storages.AnyAsync(x => x.AreaId == id))
@@ -423,19 +448,22 @@ public sealed class WmsService
entity.DeletedAt = DateTimeOffset.UtcNow; entity.DeletedAt = DateTimeOffset.UtcNow;
entity.DeletedBy = actor; entity.DeletedBy = actor;
entity.UpdatedBy = actor; entity.UpdatedBy = actor;
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
} }
public async Task<ContainerLocation> BindOrTransferLocation(ContainerLocationRequest req, string actor) public async Task<ContainerLocation> BindOrTransferLocation(ContainerLocationRequest req, string actor)
{ {
await _refs.EnsureContainerAsync(req.ContainerId); await _refs.EnsureContainerAsync(req.ContainerId);
var (code, name) = await _refs.ResolveLocationSnapshotAsync(req.LocationType, req.LocationId); var (code, name) = await _refs.ResolveLocationSnapshotAsync(req.LocationType, req.LocationId);
if (!ContainerLocationStatuses.All.Contains(req.Status)) if (!ContainerLocationStatuses.IsDefined(req.Status))
throw new InvalidOperationException("容器位置状态无效"); throw new InvalidOperationException("容器位置状态无效");
var locationType = ContainerLocationTypes.ParseOr(req.LocationType);
var locationStatus = ContainerLocationStatuses.ParseOr(req.Status);
Guid? fromStorageId = null; Guid? fromStorageId = null;
Guid? toStorageId = null; Guid? toStorageId = null;
if (string.Equals(req.LocationType, ContainerLocationTypes.Storage, StringComparison.OrdinalIgnoreCase) && if (locationType == ContainerLocationTypes.Storage &&
Guid.TryParse(req.LocationId, out var targetStorageId)) Guid.TryParse(req.LocationId, out var targetStorageId))
{ {
toStorageId = targetStorageId; toStorageId = targetStorageId;
@@ -455,7 +483,9 @@ public sealed class WmsService
var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == req.ContainerId); var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == req.ContainerId);
var before = current == null ? null : Snapshot(current); 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; fromStorageId = fs;
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
@@ -471,11 +501,12 @@ public sealed class WmsService
EnsureUnlocked(current); EnsureUnlocked(current);
} }
current.LocationType = req.LocationType; current.LocationType = locationType;
current.LocationId = req.LocationId.Trim(); current.LocationId = req.LocationId.Trim();
current.StorageId = toStorageId;
current.LocationCode = code; current.LocationCode = code;
current.LocationName = name; current.LocationName = name;
current.Status = req.Status; current.Status = locationStatus;
current.EnteredAt = req.EnteredAt ?? now; current.EnteredAt = req.EnteredAt ?? now;
ApplyCommon(current, req, actor); ApplyCommon(current, req, actor);
@@ -486,7 +517,7 @@ public sealed class WmsService
EventType = before == null ? "Bind" : "Transfer", EventType = before == null ? "Bind" : "Transfer",
FromLocationType = before?.LocationType ?? "", FromLocationType = before?.LocationType ?? "",
FromLocationId = before?.LocationId ?? "", FromLocationId = before?.LocationId ?? "",
ToLocationType = current.LocationType, ToLocationType = current.LocationType.ToString(),
ToLocationId = current.LocationId, ToLocationId = current.LocationId,
BeforeJson = before == null ? "{}" : JsonSerializer.Serialize(before, _json), BeforeJson = before == null ? "{}" : JsonSerializer.Serialize(before, _json),
AfterJson = JsonSerializer.Serialize(Snapshot(current), _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 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(containerId: req.ContainerId, storageId: fromStorageId);
await SyncOccupancyStatus(storageId: toStorageId); await SyncOccupancyStatus(storageId: toStorageId);
return current; return current;
@@ -520,7 +551,7 @@ public sealed class WmsService
RelationId = current.Id, RelationId = current.Id,
ContainerId = current.ContainerId, ContainerId = current.ContainerId,
EventType = "Unbind", EventType = "Unbind",
FromLocationType = current.LocationType, FromLocationType = current.LocationType.ToString(),
FromLocationId = current.LocationId, FromLocationId = current.LocationId,
BeforeJson = JsonSerializer.Serialize(before, _json), BeforeJson = JsonSerializer.Serialize(before, _json),
AfterJson = "{}", AfterJson = "{}",
@@ -531,7 +562,7 @@ public sealed class WmsService
}); });
await AddContainerMoveEventAsync(containerId, fromStorageId, null, actor, reason, now); await AddContainerMoveEventAsync(containerId, fromStorageId, null, actor, reason, now);
_db.ContainerLocations.Remove(current); _db.ContainerLocations.Remove(current);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: containerId, storageId: fromStorageId); await SyncOccupancyStatus(containerId: containerId, storageId: fromStorageId);
} }
@@ -578,7 +609,7 @@ public sealed class WmsService
material.UnboundAt = null; material.UnboundAt = null;
await AddBindUnbindEventAsync(StockEventTypes.Bind, material, req.ContainerId, actor, req.Reason.TrimOr(""), now); await AddBindUnbindEventAsync(StockEventTypes.Bind, material, req.ContainerId, actor, req.Reason.TrimOr(""), now);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: req.ContainerId); await SyncOccupancyStatus(containerId: req.ContainerId);
return current; return current;
} }
@@ -620,7 +651,7 @@ public sealed class WmsService
} }
_db.ContainerMaterials.Remove(current); _db.ContainerMaterials.Remove(current);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: containerId); 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") public async Task<Warehouse> EnsureDefaultWarehouseAsync(string actor = "system")
@@ -683,7 +714,7 @@ public sealed class WmsService
}; };
StampCreate(wh, actor); StampCreate(wh, actor);
_db.Warehouses.Add(wh); _db.Warehouses.Add(wh);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
return wh; return wh;
} }
@@ -692,44 +723,26 @@ public sealed class WmsService
var wh = await EnsureDefaultWarehouseAsync(actor); var wh = await EnsureDefaultWarehouseAsync(actor);
var areas = await _db.WarehouseAreas.Where(x => x.WarehouseId == Guid.Empty).ToListAsync(); var areas = await _db.WarehouseAreas.Where(x => x.WarehouseId == Guid.Empty).ToListAsync();
foreach (var a in areas) foreach (var a in areas)
{
a.WarehouseId = wh.Id; a.WarehouseId = wh.Id;
if (string.IsNullOrWhiteSpace(a.LayoutMode)) a.LayoutMode = AreaLayoutModes.Flat;
}
var storages = await _db.Storages.ToListAsync(); var storages = await _db.Storages.ToListAsync();
foreach (var s in storages) foreach (var s in storages)
{ {
if (string.IsNullOrWhiteSpace(s.LocationKind))
s.LocationKind = LocationKinds.Station;
if (s.LevelNo <= 0) s.LevelNo = 1; if (s.LevelNo <= 0) s.LevelNo = 1;
if (s.DepthNo <= 0) s.DepthNo = 1; if (s.DepthNo <= 0) s.DepthNo = 1;
if (string.IsNullOrWhiteSpace(s.SiteCode)) s.SiteCode = s.SiteId; if (string.IsNullOrWhiteSpace(s.SiteCode)) s.SiteCode = s.SiteId;
s.Status = StorageStatuses.Normalize(s.Status) switch if (!StorageStatuses.All.Contains(s.Status))
{ s.Status = StorageStatuses.Empty;
StorageStatuses.Available or StorageStatuses.Idle => StorageStatuses.Empty,
StorageStatuses.Occupied => StorageStatuses.FullContainer,
var x => x
};
if (!s.Enabled) s.Status = StorageStatuses.Disabled; if (!s.Enabled) s.Status = StorageStatuses.Disabled;
} }
var containers = await _db.Containers.ToListAsync(); var containers = await _db.Containers.ToListAsync();
foreach (var c in containers) foreach (var c in containers)
{ {
c.Status = c.Status switch if (!ContainerStatuses.All.Contains(c.Status))
{ c.Status = ContainerStatuses.EmptyMaterial;
"Idle" or "Empty" => ContainerStatuses.EmptyMaterial,
"Loaded" => ContainerStatuses.FullMaterial,
_ when 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(); var binds = await _db.ContainerMaterials.ToListAsync();
foreach (var b in binds) foreach (var b in binds)
{ {
@@ -739,7 +752,7 @@ public sealed class WmsService
b.Status = ContainerMaterialStatuses.Bound; b.Status = ContainerMaterialStatuses.Bound;
} }
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
foreach (var s in storages.Where(x => x.Status != StorageStatuses.Disabled)) foreach (var s in storages.Where(x => x.Status != StorageStatuses.Disabled))
await SyncOccupancyStatus(storageId: s.Id); await SyncOccupancyStatus(storageId: s.Id);
@@ -773,13 +786,14 @@ public sealed class WmsService
if (exists) throw new InvalidOperationException("同库区网格坐标已存在"); 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 ctn = await _db.Containers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == containerId);
var loc = await _db.ContainerLocations.AsNoTracking().FirstOrDefaultAsync(x => x.ContainerId == containerId); var loc = await _db.ContainerLocations.AsNoTracking().FirstOrDefaultAsync(x => x.ContainerId == containerId);
Storage? storage = null; Storage? storage = null;
WarehouseArea? area = 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); storage = await _db.Storages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == sid);
if (storage != null) 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 private Task<T> FindEditable<T>(Guid id, long? version)
{ where T : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
var entity = await set.FirstOrDefaultAsync(x => x.Id == id) ?? throw new InvalidOperationException("数据不存在"); => _services.GetRequiredService<IEditableRepository<T>>().GetEditableAsync(id, version);
EnsureVersion(entity, version);
EnsureUnlocked(entity);
return entity;
}
private static void EnsureVersion(EntityBase entity, long? version) private static void EnsureVersion(EntityBase entity, long? version)
{ {
if (version.HasValue && entity.Version != version.Value) 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) 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) private static void StampCreate(EntityBase entity, string actor)
@@ -909,73 +921,14 @@ public sealed class WmsService
} }
private static ContainerLocationSnapshot Snapshot(ContainerLocation x) => new( 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( 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 class WmsStringExtensions
{ {
public static string TrimOr(this string? value, string fallback) => 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) public async Task<TransportCandidatePreview> PreviewAsync(WmsTransportRequest request)
{ {
if (!WmsTransportTriggerTypes.All.Contains(request.TriggerType)) if (!WmsTransportTriggerTypes.IsDefined(request.TriggerType))
throw new InvalidOperationException("触发类型无效"); throw new InvalidOperationException("触发类型无效");
var trigger = WmsTransportTriggerTypes.ParseOr(request.TriggerType);
var ctx = await BuildContextAsync(); var ctx = await BuildContextAsync();
var rules = await LoadRulesAsync(request); var rules = await LoadRulesAsync(request, trigger);
var ruleResults = new List<RulePreviewResult>(); var ruleResults = new List<RulePreviewResult>();
var candidates = new List<TransportCandidatePair>(); var candidates = new List<TransportCandidatePair>();
@@ -26,7 +27,7 @@ public sealed class WmsTransportPlanner
var sourceSelector = TransportSelectorParser.ParseSelector(rule.SourceSelectorJson); var sourceSelector = TransportSelectorParser.ParseSelector(rule.SourceSelectorJson);
var targetSelector = TransportSelectorParser.ParseSelector(rule.TargetSelectorJson); var targetSelector = TransportSelectorParser.ParseSelector(rule.TargetSelectorJson);
var options = TransportSelectorParser.ParseTaskOptions(rule.TaskOptionsJson); 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 var rejectReasons = ruleCandidates.Count == 0
? new List<string> { "未找到满足条件的起点/终点组合" } ? new List<string> { "未找到满足条件的起点/终点组合" }
: new List<string>(); : new List<string>();
@@ -52,10 +53,10 @@ public sealed class WmsTransportPlanner
return preview.Candidates.FirstOrDefault(); 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() var query = _db.WmsTransportRules.AsNoTracking()
.Where(x => x.Enabled && x.TriggerType == request.TriggerType); .Where(x => x.Enabled && x.TriggerType == trigger);
if (request.RuleId.HasValue) if (request.RuleId.HasValue)
query = query.Where(x => x.Id == request.RuleId.Value); query = query.Where(x => x.Id == request.RuleId.Value);
return await query.OrderByDescending(x => x.Priority).ThenBy(x => x.Code).ToListAsync(); return await query.OrderByDescending(x => x.Priority).ThenBy(x => x.Code).ToListAsync();
@@ -94,17 +95,18 @@ public sealed class WmsTransportPlanner
private List<TransportCandidatePair> BuildCandidates( private List<TransportCandidatePair> BuildCandidates(
PlannerContext ctx, PlannerContext ctx,
WmsTransportRequest request, WmsTransportRequest request,
WmsTransportTriggerType trigger,
WmsTransportRule rule, WmsTransportRule rule,
TransportSelector sourceSelector, TransportSelector sourceSelector,
TransportSelector targetSelector, TransportSelector targetSelector,
TransportTaskOptions options) TransportTaskOptions options)
{ {
var results = new List<TransportCandidatePair>(); 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) 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) foreach (var tgt in targets)
{ {
var score = rule.Priority * 1000 + src.Score + tgt.Score; var score = rule.Priority * 1000 + src.Score + tgt.Score;
@@ -124,6 +126,7 @@ public sealed class WmsTransportPlanner
private List<SourceCandidate> BuildSourceCandidates( private List<SourceCandidate> BuildSourceCandidates(
PlannerContext ctx, PlannerContext ctx,
WmsTransportRequest request, WmsTransportRequest request,
WmsTransportTriggerType trigger,
TransportSelector selector, TransportSelector selector,
TransportTaskOptions options) TransportTaskOptions options)
{ {
@@ -145,7 +148,7 @@ public sealed class WmsTransportPlanner
continue; continue;
if (request.RequestSiteId is { Length: > 0 } siteId && 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)) !string.Equals(storage.SiteId, siteId, StringComparison.OrdinalIgnoreCase))
continue; continue;
@@ -162,6 +165,7 @@ public sealed class WmsTransportPlanner
private List<TargetCandidate> BuildTargetCandidates( private List<TargetCandidate> BuildTargetCandidates(
PlannerContext ctx, PlannerContext ctx,
WmsTransportRequest request, WmsTransportRequest request,
WmsTransportTriggerType trigger,
TransportSelector selector, TransportSelector selector,
TransportTaskOptions options, TransportTaskOptions options,
Guid sourceStorageId) Guid sourceStorageId)
@@ -176,7 +180,7 @@ public sealed class WmsTransportPlanner
if (ctx.ReservedTargetStorages.Contains(storage.Id)) continue; if (ctx.ReservedTargetStorages.Contains(storage.Id)) continue;
if (request.RequestSiteId is { Length: > 0 } siteId && if (request.RequestSiteId is { Length: > 0 } siteId &&
string.Equals(request.TriggerType, WmsTransportTriggerTypes.MaterialCall, StringComparison.OrdinalIgnoreCase) && trigger == WmsTransportTriggerTypes.MaterialCall &&
selector.Storage?.SiteIds is { Count: 0 } && selector.Storage?.SiteIds is { Count: 0 } &&
!string.Equals(storage.SiteId, siteId, StringComparison.OrdinalIgnoreCase)) !string.Equals(storage.SiteId, siteId, StringComparison.OrdinalIgnoreCase))
continue; continue;
@@ -200,7 +204,8 @@ public sealed class WmsTransportPlanner
{ {
if (request.MaterialId.HasValue && row.MaterialId != request.MaterialId.Value) continue; 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?.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 (filter?.MinQuantity is { } min && row.Quantity < min) continue;
if (request.Quantity is { } reqQty && row.Quantity < reqQty) 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; 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.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.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.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.SiteIds is { Count: > 0 } && !filter.SiteIds.Contains(storage.SiteId, StringComparer.OrdinalIgnoreCase)) return false;
if (filter.AllowInbound == true && !storage.AllowInbound) return false; if (filter.AllowInbound == true && !storage.AllowInbound) return false;
if (filter.AllowOutbound == true && !storage.AllowOutbound) 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 == null) return true;
if (filter.ContainerTypes is { Count: > 0 } && !filter.ContainerTypes.Contains(container.ContainerType, StringComparer.OrdinalIgnoreCase)) return false; 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 && if (filter.ExcludeReserved &&
(ctx.ReservedContainers.Contains(container.Id) || ctx.TaskReservedContainers.Contains(container.Id))) (ctx.ReservedContainers.Contains(container.Id) || ctx.TaskReservedContainers.Contains(container.Id)))
return false; return false;
+24 -30
View File
@@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MiGu.DB.Abstractions.Exceptions;
using MiGu.DB.Abstractions.Persistence;
using MiGu.Server.Persistence; using MiGu.Server.Persistence;
namespace MiGu.Server.Wms; namespace MiGu.Server.Wms;
@@ -6,14 +8,26 @@ namespace MiGu.Server.Wms;
public sealed class WmsTransportRuleService public sealed class WmsTransportRuleService
{ {
private readonly PlatformDbContext _db; 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) 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(); var query = _db.WmsTransportRules.AsNoTracking().OrderByDescending(x => x.Priority).ThenBy(x => x.Code).AsQueryable();
if (!string.IsNullOrWhiteSpace(triggerType)) if (!string.IsNullOrWhiteSpace(triggerType) &&
query = query.Where(x => x.TriggerType == 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)) if (!string.IsNullOrWhiteSpace(q))
{ {
var s = q.Trim(); var s = q.Trim();
@@ -24,12 +38,12 @@ public sealed class WmsTransportRuleService
public async Task<WmsTransportRule> SaveAsync(TransportRuleRequest req, string actor) public async Task<WmsTransportRule> SaveAsync(TransportRuleRequest req, string actor)
{ {
if (!WmsTransportTriggerTypes.All.Contains(req.TriggerType)) if (!WmsTransportTriggerTypes.IsDefined(req.TriggerType))
throw new InvalidOperationException("触发类型无效"); throw new InvalidOperationException("触发类型无效");
WmsTransportRule entity; WmsTransportRule entity;
if (req.Id.HasValue) if (req.Id.HasValue)
entity = await FindEditable(req.Id.Value, req.Version); entity = await _rules.GetEditableAsync(req.Id.Value, req.Version);
else else
{ {
entity = new WmsTransportRule(); entity = new WmsTransportRule();
@@ -37,44 +51,24 @@ public sealed class WmsTransportRuleService
_db.WmsTransportRules.Add(entity); _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.Code = req.Code.Trim();
entity.Name = req.Name.Trim(); entity.Name = req.Name.Trim();
entity.TriggerType = req.TriggerType.Trim(); entity.TriggerType = WmsTransportTriggerTypes.ParseOr(req.TriggerType);
entity.Enabled = req.Enabled; entity.Enabled = req.Enabled;
entity.Priority = req.Priority; entity.Priority = req.Priority;
entity.SourceSelectorJson = TransportSelectorParser.NormalizeSelectorJson(req.SourceSelectorJson); entity.SourceSelectorJson = TransportSelectorParser.NormalizeSelectorJson(req.SourceSelectorJson);
entity.TargetSelectorJson = TransportSelectorParser.NormalizeSelectorJson(req.TargetSelectorJson); entity.TargetSelectorJson = TransportSelectorParser.NormalizeSelectorJson(req.TargetSelectorJson);
entity.TaskOptionsJson = TransportSelectorParser.NormalizeTaskOptionsJson(req.TaskOptionsJson); entity.TaskOptionsJson = TransportSelectorParser.NormalizeTaskOptionsJson(req.TaskOptionsJson);
ApplyCommon(entity, req, actor); ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync(); await _uow.SaveChangesAsync();
return entity; return entity;
} }
public async Task DeleteAsync(Guid id, long? version, string actor) public async Task DeleteAsync(Guid id, long? version, string actor)
{ {
var entity = await FindEditable(id, version); await _rules.SoftDeleteAsync(id, version);
entity.IsDeleted = true; await _uow.SaveChangesAsync();
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("规则编码已存在");
} }
private static void StampCreate(EntityBase entity, string actor) 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) public async Task<List<WmsTransportTask>> ListAsync(string? status = null, string? triggerType = null)
{ {
var query = _db.WmsTransportTasks.AsNoTracking().AsQueryable(); var query = _db.WmsTransportTasks.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(status)) if (!string.IsNullOrWhiteSpace(status) &&
query = query.Where(x => x.Status == 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)) if (!string.IsNullOrWhiteSpace(triggerType))
query = query.Where(x => x.BusinessType == triggerType); query = query.Where(x => x.BusinessType == triggerType);
var rows = await query.ToListAsync(); return await query.OrderByDescending(x => x.CreatedAt).Take(500).ToListAsync();
return rows.OrderByDescending(x => x.CreatedAt).Take(500).ToList();
} }
public Task<TransportCandidatePreview> PreviewCandidatesAsync(WmsTransportRequest request) => public Task<TransportCandidatePreview> PreviewCandidatesAsync(WmsTransportRequest request) =>
@@ -80,7 +81,7 @@ public sealed class WmsTransportTaskService
if (status == WmsTransportTaskStatuses.Reserved) if (status == WmsTransportTaskStatuses.Reserved)
await CreateReservationAsync(task, options, actor); 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(); await tx.CommitAsync();
return task; return task;
} }
@@ -147,8 +148,8 @@ public sealed class WmsTransportTaskService
try try
{ {
await _wms.BindOrTransferLocation(new ContainerLocationRequest( await _wms.BindOrTransferLocation(new ContainerLocationRequest(
null, null, task.ContainerId, ContainerLocationTypes.Storage, null, null, task.ContainerId, ContainerLocationTypes.Storage.ToString(),
task.TargetStorageId.ToString("D"), ContainerLocationStatuses.Active, task.TargetStorageId.ToString("D"), ContainerLocationStatuses.Active.ToString(),
DateTimeOffset.UtcNow, "TransportTask", reason, false, "", "{}"), actor); DateTimeOffset.UtcNow, "TransportTask", reason, false, "", "{}"), actor);
await ReleaseReservationsAsync(task.Id); await ReleaseReservationsAsync(task.Id);
@@ -245,13 +246,13 @@ public sealed class WmsTransportTaskService
await _db.SaveChangesAsync(); 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; var from = task.Status;
task.Status = toStatus; task.Status = toStatus;
task.UpdatedBy = actor; task.UpdatedBy = actor;
await _db.SaveChangesAsync(); 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) 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" "Yarp": "Debug"
} }
}, },
"Database": {
"SchemaMode": "EnsureCreated"
},
"SimpleLite": { "SimpleLite": {
"Enabled": true, "Enabled": true,
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe", "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.