From bdcd88608c21eace20f45662ecd63358628306a4 Mon Sep 17 00:00:00 2001 From: ArtoriasWu Date: Fri, 24 Jul 2026 10:19:36 +0800 Subject: [PATCH] =?UTF-8?q?WMS=E4=B8=BB=E6=95=B0=E6=8D=AE=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8D=87=E7=BA=A7=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BB=93?= =?UTF-8?q?=E5=BA=93/=E7=89=A9=E6=96=99=E7=B1=BB=E5=9E=8B=E7=AD=89?= =?UTF-8?q?=E6=96=B0=E5=AE=9E=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端:重构WmsController/WmsService,新增仓库、库区、货位、物料类型等实体及其增删改查接口,支持批量生成网格货位、货位/容器锁定与启用、物料归档与批量清理。数据库模型新增Warehouse、MaterialType、StockEvent等表,调整索引和字段,支持SQLite自动补表/补列及数据迁移,接口兼容旧语义。 前端:wms.ts/types/wms.ts全面适配新结构,升级API路由和类型定义。WarehouseManagementView.vue重构,支持仓库、物料类型管理、货位网格可视化、库存事件展示等。WmsEntityDialog.vue支持新字段编辑及联动选择。新增AreaFacadePanel.vue实现库区网格立面可视化。wmsOptions.ts新增相关常量及UI优化。 兼容性:后端自动迁移旧数据,接口兼容旧绑定语义,提升灵活性、可维护性和可视化能力。 --- MiGu.Server/Controllers/WmsController.cs | 123 ++- MiGu.Server/Persistence/PlatformDbContext.cs | 45 +- .../Persistence/PlatformPersistence.cs | 125 +++ MiGu.Server/Wms/WmsModels.cs | 145 +++- MiGu.Server/Wms/WmsReferenceValidator.cs | 12 + MiGu.Server/Wms/WmsService.cs | 787 ++++++++++++++---- .../apps/simple-platform-vue/src/api/wms.ts | 104 ++- .../apps/simple-platform-vue/src/types/wms.ts | 169 +++- .../views/admin/WarehouseManagementView.vue | 282 ++++++- .../views/admin/warehouse/AreaFacadePanel.vue | 159 ++++ .../views/admin/warehouse/WmsEntityDialog.vue | 85 +- .../src/views/admin/warehouse/wmsOptions.ts | 46 +- 12 files changed, 1794 insertions(+), 288 deletions(-) create mode 100644 frontends/apps/simple-platform-vue/src/views/admin/warehouse/AreaFacadePanel.vue diff --git a/MiGu.Server/Controllers/WmsController.cs b/MiGu.Server/Controllers/WmsController.cs index 1064526..7c02199 100644 --- a/MiGu.Server/Controllers/WmsController.cs +++ b/MiGu.Server/Controllers/WmsController.cs @@ -22,14 +22,34 @@ public sealed class WmsController : ControllerBase _transportTasks = transportTasks; } + [HttpGet("warehouses")] + public Task> Warehouses([FromQuery] string? q) => _service.Warehouses(q); + + [HttpPost("warehouses")] + public async Task SaveWarehouse([FromBody] MasterDataRequest req) => + Ok(await _service.SaveWarehouse(req, User.ActorName())); + + [HttpPut("warehouses/{id:guid}")] + public async Task UpdateWarehouse(Guid id, [FromBody] MasterDataRequest req) => + Ok(await _service.SaveWarehouse(req with { Id = id }, User.ActorName())); + + [HttpDelete("warehouses/{id:guid}")] + public async Task DeleteWarehouse(Guid id, [FromQuery] long? version) + { + await _service.DeleteEntity(id, version, User.ActorName()); + return NoContent(); + } + [HttpGet("areas")] - public Task> Areas([FromQuery] string? q) => _service.Areas(q); + public Task> Areas([FromQuery] string? q, [FromQuery] Guid? warehouseId) => + _service.Areas(q, warehouseId); [HttpPost("areas")] - public async Task SaveArea([FromBody] MasterDataRequest req) => Ok(await _service.SaveArea(req, User.ActorName())); + public async Task SaveArea([FromBody] AreaRequest req) => + Ok(await _service.SaveArea(req, User.ActorName())); [HttpPut("areas/{id:guid}")] - public async Task UpdateArea(Guid id, [FromBody] MasterDataRequest req) => + public async Task UpdateArea(Guid id, [FromBody] AreaRequest req) => Ok(await _service.SaveArea(req with { Id = id }, User.ActorName())); [HttpDelete("areas/{id:guid}")] @@ -40,10 +60,12 @@ public sealed class WmsController : ControllerBase } [HttpGet("storages")] - public Task> Storages([FromQuery] string? q) => _service.Storages(q); + public Task> Storages([FromQuery] string? q, [FromQuery] Guid? areaId, [FromQuery] string? locationKind) => + _service.Storages(q, areaId, locationKind); [HttpPost("storages")] - public async Task SaveStorage([FromBody] StorageRequest req) => Ok(await _service.SaveStorage(req, User.ActorName())); + public async Task SaveStorage([FromBody] StorageRequest req) => + Ok(await _service.SaveStorage(req, User.ActorName())); [HttpPut("storages/{id:guid}")] public async Task UpdateStorage(Guid id, [FromBody] StorageRequest req) => @@ -56,14 +78,35 @@ public sealed class WmsController : ControllerBase return NoContent(); } + [HttpPost("areas/{id:guid}/generate-bins")] + public async Task GenerateBins(Guid id, [FromBody] GenerateBinsRequest req) => + Ok(new { created = await _service.GenerateBins(id, req, User.ActorName()) }); + + [HttpPost("storages/{id:guid}/lock")] + public async Task LockStorage(Guid id, [FromQuery] long? version) => + Ok(await _service.SetStorageLock(id, true, version, User.ActorName())); + + [HttpPost("storages/{id:guid}/unlock")] + public async Task UnlockStorage(Guid id, [FromQuery] long? version) => + Ok(await _service.SetStorageLock(id, false, version, User.ActorName())); + + [HttpPost("storages/{id:guid}/enable")] + public async Task EnableStorage(Guid id, [FromQuery] long? version) => + Ok(await _service.SetStorageEnabled(id, true, version, User.ActorName())); + + [HttpPost("storages/{id:guid}/disable")] + public async Task DisableStorage(Guid id, [FromQuery] long? version) => + Ok(await _service.SetStorageEnabled(id, false, version, User.ActorName())); + [HttpGet("containers")] public Task> Containers([FromQuery] string? q) => _service.Containers(q); [HttpPost("containers")] - public async Task SaveContainer([FromBody] MasterDataRequest req) => Ok(await _service.SaveContainer(req, User.ActorName())); + public async Task SaveContainer([FromBody] ContainerRequest req) => + Ok(await _service.SaveContainer(req, User.ActorName())); [HttpPut("containers/{id:guid}")] - public async Task UpdateContainer(Guid id, [FromBody] MasterDataRequest req) => + public async Task UpdateContainer(Guid id, [FromBody] ContainerRequest req) => Ok(await _service.SaveContainer(req with { Id = id }, User.ActorName())); [HttpDelete("containers/{id:guid}")] @@ -73,11 +116,35 @@ public sealed class WmsController : ControllerBase return NoContent(); } + [HttpGet("material-types")] + public Task> MaterialTypes([FromQuery] string? q) => _service.MaterialTypes(q); + + [HttpPost("material-types")] + public async Task SaveMaterialType([FromBody] MaterialTypeRequest req) => + Ok(await _service.SaveMaterialType(req, User.ActorName())); + + [HttpPut("material-types/{id:guid}")] + public async Task UpdateMaterialType(Guid id, [FromBody] MaterialTypeRequest req) => + Ok(await _service.SaveMaterialType(req with { Id = id }, User.ActorName())); + + [HttpDelete("material-types/{id:guid}")] + public async Task DeleteMaterialType(Guid id, [FromQuery] long? version) + { + await _service.DeleteEntity(id, version, User.ActorName()); + return NoContent(); + } + [HttpGet("materials")] - public Task> Materials([FromQuery] string? q) => _service.Materials(q); + public Task> Materials( + [FromQuery] string? q, + [FromQuery] string? lifecycle, + [FromQuery] bool? onlyUnbound, + [FromQuery] bool? onlyBound) => + _service.Materials(q, lifecycle, onlyUnbound, onlyBound); [HttpPost("materials")] - public async Task SaveMaterial([FromBody] MaterialRequest req) => Ok(await _service.SaveMaterial(req, User.ActorName())); + public async Task SaveMaterial([FromBody] MaterialRequest req) => + Ok(await _service.SaveMaterial(req, User.ActorName())); [HttpPut("materials/{id:guid}")] public async Task UpdateMaterial(Guid id, [FromBody] MaterialRequest req) => @@ -90,6 +157,17 @@ public sealed class WmsController : ControllerBase return NoContent(); } + [HttpPost("materials/{id:guid}/archive")] + public async Task ArchiveMaterial(Guid id, [FromQuery] long? version) => + Ok(await _service.ArchiveMaterial(id, version, User.ActorName())); + + [HttpPost("materials/purge-archived")] + public async Task PurgeArchivedMaterials([FromQuery] int take = 200) + { + await _service.PurgeArchivedMaterials(User.ActorName(), take); + return NoContent(); + } + [HttpGet("container-locations")] public Task> ContainerLocations([FromQuery] string? locationType, [FromQuery] string? q) => _service.ContainerLocations(locationType, q); @@ -110,23 +188,34 @@ public sealed class WmsController : ControllerBase } [HttpGet("container-materials")] - public Task> ContainerMaterials([FromQuery] string? q) => _service.ContainerMaterials(q); + public Task> ContainerMaterials([FromQuery] string? q, [FromQuery] Guid? containerId) => + _service.ContainerMaterials(q, containerId); [HttpPost("container-materials")] - public async Task SaveContainerMaterial([FromBody] ContainerMaterialRequest req) => - Ok(await _service.SaveContainerMaterial(req, User.ActorName())); + public async Task SaveContainerMaterial([FromBody] BindMaterialRequest req) => + Ok(await _service.BindMaterial(req, User.ActorName())); - [HttpPut("container-materials/{id:guid}")] - public async Task UpdateContainerMaterial(Guid id, [FromBody] ContainerMaterialRequest req) => - Ok(await _service.SaveContainerMaterial(req with { Id = id }, User.ActorName())); + [HttpPost("container-materials/bind")] + public async Task BindMaterial([FromBody] BindMaterialRequest req) => + Ok(await _service.BindMaterial(req, User.ActorName())); [HttpDelete("container-materials/{id:guid}")] - public async Task UnloadContainerMaterial(Guid id, [FromQuery] string? reason) + public async Task UnbindMaterial(Guid id, [FromQuery] string? reason, [FromQuery] bool archive = false) { - await _service.UnloadMaterial(id, User.ActorName(), reason ?? ""); + await _service.UnbindMaterial(id, User.ActorName(), reason ?? "", archive); return NoContent(); } + [HttpGet("inventory/materials")] + public Task> InventoryMaterials( + [FromQuery] Guid? areaId, [FromQuery] Guid? storageId, [FromQuery] string? q) => + _service.InventoryMaterials(areaId, storageId, q); + + [HttpGet("stock-events")] + public Task> StockEvents( + [FromQuery] string? eventType, [FromQuery] Guid? materialId, [FromQuery] Guid? containerId) => + _service.StockEvents(eventType, materialId, containerId); + [HttpGet("container-location-history")] public Task> ContainerLocationHistory([FromQuery] Guid? containerId) => _service.LocationHistory(containerId); diff --git a/MiGu.Server/Persistence/PlatformDbContext.cs b/MiGu.Server/Persistence/PlatformDbContext.cs index cdcb544..4b83d0e 100644 --- a/MiGu.Server/Persistence/PlatformDbContext.cs +++ b/MiGu.Server/Persistence/PlatformDbContext.cs @@ -10,12 +10,15 @@ public sealed class PlatformDbContext : DbContext { public PlatformDbContext(DbContextOptions options) : base(options) { } + public DbSet Warehouses => Set(); public DbSet WarehouseAreas => Set(); public DbSet Storages => Set(); public DbSet Containers => Set(); + public DbSet MaterialTypes => Set(); public DbSet Materials => Set(); public DbSet ContainerLocations => Set(); public DbSet ContainerMaterials => Set(); + public DbSet StockEvents => Set(); public DbSet ContainerLocationHistories => Set(); public DbSet ContainerMaterialHistories => Set(); public DbSet WmsTransportRules => Set(); @@ -52,9 +55,11 @@ public sealed class PlatformDbContext : DbContext modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableDateTimeOffset).HasMaxLength(40); } + ConfigureEntityBase(modelBuilder, "wms_warehouses"); ConfigureEntityBase(modelBuilder, "wms_areas"); ConfigureEntityBase(modelBuilder, "wms_storages"); ConfigureEntityBase(modelBuilder, "wms_containers"); + ConfigureEntityBase(modelBuilder, "wms_material_types"); ConfigureEntityBase(modelBuilder, "wms_materials"); ConfigureEntityBase(modelBuilder, "wms_container_locations"); ConfigureEntityBase(modelBuilder, "wms_container_materials"); @@ -64,16 +69,25 @@ public sealed class PlatformDbContext : DbContext ConfigureHistory(modelBuilder, "wms_container_location_history"); ConfigureHistory(modelBuilder, "wms_container_material_history"); + ConfigureStockEvent(modelBuilder); ConfigureTransportTaskHistory(modelBuilder); + modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.WarehouseId); modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); modelBuilder.Entity().HasIndex(x => x.AreaId); + modelBuilder.Entity().HasIndex(x => x.Barcode); modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Barcode); + modelBuilder.Entity().HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt }); + modelBuilder.Entity().HasIndex(x => x.TypeCode); modelBuilder.Entity().HasIndex(x => x.ContainerId).IsUnique(); modelBuilder.Entity().HasIndex(x => new { x.LocationType, x.LocationId }); - modelBuilder.Entity().HasIndex(x => new { x.ContainerId, x.MaterialId, x.BatchNo, x.SerialNo }).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.MaterialId).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.ContainerId); modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); modelBuilder.Entity().HasIndex(x => new { x.TriggerType, x.Enabled, x.Priority }); @@ -193,6 +207,35 @@ public sealed class PlatformDbContext : DbContext e.HasIndex(x => x.EventType); } + private static void ConfigureStockEvent(ModelBuilder modelBuilder) + { + var e = modelBuilder.Entity(); + 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(); diff --git a/MiGu.Server/Persistence/PlatformPersistence.cs b/MiGu.Server/Persistence/PlatformPersistence.cs index a4932a0..f4b45e6 100644 --- a/MiGu.Server/Persistence/PlatformPersistence.cs +++ b/MiGu.Server/Persistence/PlatformPersistence.cs @@ -58,6 +58,131 @@ public static class PlatformPersistence 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(); + await wms.MigrateLegacyAsync("system"); + } + catch + { + // 首次建库或缺列时忽略,后续请求可再触发 + } + } + + /// 补建仓库/物料类型/库存事件及结构扩展列(幂等,SQLite)。 + 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 ''"); } /// 为已存在的数据库补建 simple_fields 表(幂等)。 diff --git a/MiGu.Server/Wms/WmsModels.cs b/MiGu.Server/Wms/WmsModels.cs index 9e52b1a..5a9d2f3 100644 --- a/MiGu.Server/Wms/WmsModels.cs +++ b/MiGu.Server/Wms/WmsModels.cs @@ -19,11 +19,23 @@ public abstract class WarehouseHistoryBase public string Extend { get; set; } = "{}"; } -public sealed class WarehouseArea : EntityBase +public sealed class Warehouse : EntityBase { + [MaxLength(64)] public string Code { get; set; } = ""; + [MaxLength(128)] public string Name { get; set; } = ""; + [MaxLength(64)] public string Type { get; set; } = "Default"; + public bool Enabled { get; set; } = true; + public int SortOrder { get; set; } +} + +public sealed class WarehouseArea : EntityBase +{ + public Guid WarehouseId { get; set; } [MaxLength(64)] public string Code { get; set; } = ""; [MaxLength(128)] public string Name { get; set; } = ""; [MaxLength(64)] public string Type { get; set; } = "Storage"; + [MaxLength(32)] public string LayoutMode { get; set; } = AreaLayoutModes.Flat; + [MaxLength(32)] public string State { get; set; } = "Default"; public bool Enabled { get; set; } = true; public int SortOrder { get; set; } } @@ -34,9 +46,16 @@ public sealed class Storage : EntityBase [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; } = ""; + /// 已废弃:一货位一容器,保留列兼容旧库。 public int Capacity { get; set; } - [MaxLength(32)] public string Status { get; set; } = StorageStatuses.Available; + [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; } = ""; @@ -47,10 +66,26 @@ public sealed class Storage : EntityBase public sealed class Container : EntityBase { + public Guid? AreaId { get; set; } [MaxLength(64)] public string Code { get; set; } = ""; [MaxLength(128)] public string Name { get; set; } = ""; [MaxLength(64)] public string ContainerType { get; set; } = "Box"; - [MaxLength(64)] public string Status { get; set; } = "Idle"; + [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 +{ + [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; } @@ -58,9 +93,13 @@ public sealed class Material : EntityBase { [MaxLength(64)] public string Code { get; set; } = ""; [MaxLength(128)] public string Name { get; set; } = ""; + [MaxLength(64)] public string TypeCode { get; set; } = ""; + [MaxLength(128)] public string Barcode { get; set; } = ""; [MaxLength(128)] public string Spec { get; set; } = ""; [MaxLength(32)] public string Unit { get; set; } = "pcs"; [MaxLength(64)] public string Category { get; set; } = ""; + [MaxLength(32)] public string LifecycleStatus { get; set; } = MaterialLifecycles.Active; + public DateTimeOffset? UnboundAt { get; set; } public bool Enabled { get; set; } = true; } @@ -75,18 +114,50 @@ public sealed class ContainerLocation : EntityBase public DateTimeOffset EnteredAt { get; set; } } +/// 容器-物料绑定(无数量语义;Quantity 列仅兼容旧库,固定为 1)。 public sealed class ContainerMaterial : EntityBase { public Guid ContainerId { get; set; } public Guid MaterialId { get; set; } - public decimal Quantity { 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.Loaded; + [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 Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(32)] public string EventType { get; set; } = ""; + public Guid? MaterialId { get; set; } + [MaxLength(64)] public string MaterialCode { get; set; } = ""; + [MaxLength(128)] public string MaterialName { get; set; } = ""; + [MaxLength(128)] public string MaterialBarcode { get; set; } = ""; + [MaxLength(64)] public string MaterialTypeCode { get; set; } = ""; + public Guid? ContainerId { get; set; } + [MaxLength(64)] public string ContainerCode { get; set; } = ""; + [MaxLength(128)] public string ContainerName { get; set; } = ""; + public Guid? StorageId { get; set; } + [MaxLength(64)] public string StorageCode { get; set; } = ""; + [MaxLength(128)] public string StorageName { get; set; } = ""; + [MaxLength(64)] public string AreaCode { get; set; } = ""; + public Guid? FromStorageId { get; set; } + [MaxLength(64)] public string FromStorageCode { get; set; } = ""; + [MaxLength(128)] public string FromStorageName { get; set; } = ""; + public Guid? ToStorageId { get; set; } + [MaxLength(64)] public string ToStorageCode { get; set; } = ""; + [MaxLength(128)] public string ToStorageName { get; set; } = ""; + [MaxLength(64)] public string RefType { get; set; } = "Manual"; + public Guid? RefId { get; set; } + [MaxLength(64)] public string RefCode { get; set; } = ""; + [MaxLength(128)] public string Operator { get; set; } = ""; + public DateTimeOffset OperatedAt { get; set; } + [MaxLength(500)] public string Reason { get; set; } = ""; +} + public sealed class ContainerLocationHistory : WarehouseHistoryBase { [MaxLength(32)] public string FromLocationType { get; set; } = ""; @@ -101,6 +172,20 @@ public sealed class ContainerMaterialHistory : WarehouseHistoryBase public decimal QuantityDelta { get; set; } } +public static class AreaLayoutModes +{ + public const string Flat = "Flat"; + public const string Grid = "Grid"; + public static readonly HashSet All = new(StringComparer.OrdinalIgnoreCase) { Flat, Grid }; +} + +public static class LocationKinds +{ + public const string Grid = "Grid"; + public const string Station = "Station"; + public static readonly HashSet All = new(StringComparer.OrdinalIgnoreCase) { Grid, Station }; +} + public static class ContainerLocationTypes { public const string Storage = "Storage"; @@ -118,20 +203,43 @@ public static class ContainerLocationStatuses 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 All = new(StringComparer.OrdinalIgnoreCase) { Loaded, Unloaded, Adjusted, Frozen }; + public static readonly HashSet 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 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 const string Disabled = "Disabled"; - public static readonly HashSet All = new(StringComparer.OrdinalIgnoreCase) { Available, Idle, Occupied, Disabled }; + + public static readonly HashSet 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 @@ -143,6 +251,20 @@ public static class StorageTypes public const string FinishedGoods = "FinishedGoods"; } +public static class MaterialLifecycles +{ + public const string Active = "Active"; + public const string Archived = "Archived"; + public static readonly HashSet 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"; @@ -194,6 +316,7 @@ public sealed class WmsTransportTask : EntityBase public Guid TargetStorageId { get; set; } public Guid ContainerId { get; set; } public Guid? MaterialId { get; set; } + /// 已废弃:单物料实体模型不再使用数量。 public decimal? Quantity { get; set; } [MaxLength(32)] public string Status { get; set; } = WmsTransportTaskStatuses.Pending; [MaxLength(64)] public string DispatchMissionId { get; set; } = ""; @@ -227,3 +350,9 @@ public sealed class WmsTransportTaskHistory [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 = "默认仓库"; +} diff --git a/MiGu.Server/Wms/WmsReferenceValidator.cs b/MiGu.Server/Wms/WmsReferenceValidator.cs index 7e4427e..b3ef8d4 100644 --- a/MiGu.Server/Wms/WmsReferenceValidator.cs +++ b/MiGu.Server/Wms/WmsReferenceValidator.cs @@ -12,6 +12,12 @@ public sealed class WmsReferenceValidator _db = db; } + public async Task EnsureWarehouseAsync(Guid id) + { + if (!await _db.Warehouses.AnyAsync(x => x.Id == id)) + throw new InvalidOperationException("仓库不存在"); + } + public async Task EnsureAreaAsync(Guid id) { if (!await _db.WarehouseAreas.AnyAsync(x => x.Id == id)) @@ -36,6 +42,12 @@ public sealed class WmsReferenceValidator throw new InvalidOperationException("物料不存在"); } + public async Task EnsureMaterialTypeCodeAsync(string typeCode) + { + if (!await _db.MaterialTypes.AnyAsync(x => x.Code == typeCode)) + throw new InvalidOperationException("物料类型不存在"); + } + public async Task<(string Code, string Name)> ResolveLocationSnapshotAsync(string locationType, string locationId) { if (!ContainerLocationTypes.All.Contains(locationType)) diff --git a/MiGu.Server/Wms/WmsService.cs b/MiGu.Server/Wms/WmsService.cs index 7460a39..065eb4d 100644 --- a/MiGu.Server/Wms/WmsService.cs +++ b/MiGu.Server/Wms/WmsService.cs @@ -17,17 +17,43 @@ public sealed class WmsService _refs = refs; } - public Task> Areas(string? q = null) => - FilterByKeyword(_db.WarehouseAreas.AsNoTracking().OrderBy(x => x.SortOrder).ThenBy(x => x.Code), q).ToListAsync(); + public Task> Warehouses(string? q = null) => + FilterByKeyword(_db.Warehouses.AsNoTracking().OrderBy(x => x.SortOrder).ThenBy(x => x.Code), q).ToListAsync(); - public Task> Storages(string? q = null) => - FilterByKeyword(_db.Storages.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync(); + public Task> Areas(string? q = null, Guid? warehouseId = null) + { + var query = _db.WarehouseAreas.AsNoTracking().AsQueryable(); + if (warehouseId.HasValue) query = query.Where(x => x.WarehouseId == warehouseId.Value); + return FilterByKeyword(query.OrderBy(x => x.SortOrder).ThenBy(x => x.Code), q).ToListAsync(); + } + + public Task> Storages(string? q = null, Guid? areaId = null, string? locationKind = null) + { + var query = _db.Storages.AsNoTracking().AsQueryable(); + if (areaId.HasValue) query = query.Where(x => x.AreaId == areaId.Value); + if (!string.IsNullOrWhiteSpace(locationKind)) query = query.Where(x => x.LocationKind == locationKind); + return FilterByKeyword(query.OrderBy(x => x.Code), q).ToListAsync(); + } public Task> Containers(string? q = null) => FilterByKeyword(_db.Containers.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync(); - public Task> Materials(string? q = null) => - FilterByKeyword(_db.Materials.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync(); + public Task> MaterialTypes(string? q = null) => + FilterByKeyword(_db.MaterialTypes.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync(); + + public Task> Materials(string? q = null, string? lifecycle = null, bool? onlyUnbound = null, bool? onlyBound = null) + { + var query = _db.Materials.AsNoTracking().AsQueryable(); + var life = string.IsNullOrWhiteSpace(lifecycle) ? MaterialLifecycles.Active : lifecycle.Trim(); + query = query.Where(x => x.LifecycleStatus == life); + if (onlyUnbound == true || onlyBound == true) + { + var boundIds = _db.ContainerMaterials.AsNoTracking().Select(x => x.MaterialId); + if (onlyUnbound == true) query = query.Where(x => !boundIds.Contains(x.Id)); + if (onlyBound == true) query = query.Where(x => boundIds.Contains(x.Id)); + } + return FilterByKeyword(query.OrderBy(x => x.Code), q).ToListAsync(); + } public Task> ContainerLocations(string? locationType = null, string? q = null) { @@ -36,16 +62,95 @@ public sealed class WmsService return FilterByKeyword(query, q).ToListAsync(); } - public Task> ContainerMaterials(string? q = null) => - FilterByKeyword(_db.ContainerMaterials.AsNoTracking().OrderBy(x => x.ContainerId), q).ToListAsync(); - - public async Task SaveArea(MasterDataRequest req, string actor) + public Task> ContainerMaterials(string? q = null, Guid? containerId = null) { - WarehouseArea entity; - if (req.Id.HasValue) + var query = _db.ContainerMaterials.AsNoTracking().AsQueryable(); + if (containerId.HasValue) query = query.Where(x => x.ContainerId == containerId.Value); + return FilterByKeyword(query.OrderBy(x => x.ContainerId), q).ToListAsync(); + } + + public async Task> InventoryMaterials(Guid? areaId = null, Guid? storageId = null, string? q = null) + { + var binds = await _db.ContainerMaterials.AsNoTracking().ToListAsync(); + var materials = await _db.Materials.AsNoTracking().ToDictionaryAsync(x => x.Id); + var locations = await _db.ContainerLocations.AsNoTracking().ToDictionaryAsync(x => x.ContainerId); + var storages = await _db.Storages.AsNoTracking().ToDictionaryAsync(x => x.Id); + var areas = await _db.WarehouseAreas.AsNoTracking().ToDictionaryAsync(x => x.Id); + var containers = await _db.Containers.AsNoTracking().ToDictionaryAsync(x => x.Id); + + var rows = new List(); + foreach (var b in binds) { - entity = await FindEditable(_db.WarehouseAreas, req.Id.Value, req.Version); + if (!materials.TryGetValue(b.MaterialId, out var mat)) continue; + locations.TryGetValue(b.ContainerId, out var loc); + Storage? storage = null; + WarehouseArea? area = null; + if (loc != null && loc.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(loc.LocationId, out var sid)) + storages.TryGetValue(sid, out storage); + if (storage != null) areas.TryGetValue(storage.AreaId, out area); + containers.TryGetValue(b.ContainerId, out var ctn); + + if (storageId.HasValue && storage?.Id != storageId.Value) continue; + if (areaId.HasValue && area?.Id != areaId.Value) continue; + if (!string.IsNullOrWhiteSpace(q)) + { + var s = q.Trim(); + if (!(mat.Code.Contains(s) || mat.Name.Contains(s) || (ctn?.Code.Contains(s) ?? false) || (storage?.Code.Contains(s) ?? false))) + continue; + } + + rows.Add(new InventoryMaterialRow( + mat.Id, mat.Code, mat.Name, mat.Barcode, mat.TypeCode, + b.ContainerId, ctn?.Code ?? "", ctn?.Name ?? "", + storage?.Id, storage?.Code ?? "", storage?.Name ?? "", + area?.Id, area?.Code ?? "", area?.Name ?? "", + loc?.LocationType ?? "", b.BoundAt)); } + + return rows.OrderBy(x => x.MaterialCode).ToList(); + } + + public async Task> StockEvents(string? eventType = null, Guid? materialId = null, Guid? containerId = null) + { + var query = _db.StockEvents.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(eventType)) query = query.Where(x => x.EventType == eventType); + if (materialId.HasValue) query = query.Where(x => x.MaterialId == materialId.Value); + if (containerId.HasValue) query = query.Where(x => x.ContainerId == containerId.Value); + var rows = await query.ToListAsync(); + return rows.OrderByDescending(x => x.OperatedAt).Take(500).ToList(); + } + + public async Task SaveWarehouse(MasterDataRequest req, string actor) + { + Warehouse entity; + if (req.Id.HasValue) entity = await FindEditable(_db.Warehouses, req.Id.Value, req.Version); + else + { + entity = new Warehouse(); + StampCreate(entity, actor); + _db.Warehouses.Add(entity); + } + await EnsureUnique(_db.Warehouses, x => x.Code == req.Code && x.Id != entity.Id, "仓库编码已存在"); + entity.Code = req.Code.Trim(); + entity.Name = req.Name.Trim(); + entity.Type = req.Type.TrimOr("Default"); + entity.Enabled = req.Enabled; + entity.SortOrder = req.SortOrder; + ApplyCommon(entity, req, actor); + await _db.SaveChangesAsync(); + return entity; + } + + public async Task SaveArea(AreaRequest req, string actor) + { + var warehouseId = req.WarehouseId ?? Guid.Empty; + if (warehouseId == Guid.Empty) + warehouseId = (await EnsureDefaultWarehouseAsync(actor)).Id; + else + await _refs.EnsureWarehouseAsync(warehouseId); + + WarehouseArea entity; + if (req.Id.HasValue) entity = await FindEditable(_db.WarehouseAreas, req.Id.Value, req.Version); else { entity = new WarehouseArea(); @@ -53,9 +158,12 @@ public sealed class WmsService _db.WarehouseAreas.Add(entity); } await EnsureUnique(_db.WarehouseAreas, x => x.Code == req.Code && x.Id != entity.Id, "库区编码已存在"); + entity.WarehouseId = warehouseId; entity.Code = req.Code.Trim(); entity.Name = req.Name.Trim(); entity.Type = req.Type.TrimOr("Storage"); + entity.LayoutMode = AreaLayoutModes.All.Contains(req.LayoutMode) ? req.LayoutMode : AreaLayoutModes.Flat; + entity.State = req.State.TrimOr("Default"); entity.Enabled = req.Enabled; entity.SortOrder = req.SortOrder; ApplyCommon(entity, req, actor); @@ -66,11 +174,12 @@ public sealed class WmsService public async Task SaveStorage(StorageRequest req, string actor) { await _refs.EnsureAreaAsync(req.AreaId); + var kind = LocationKinds.All.Contains(req.LocationKind) ? req.LocationKind : LocationKinds.Station; + if (kind == LocationKinds.Grid) + await EnsureGridCoordUnique(req.AreaId, req.ColumnNo, req.LevelNo, req.DepthNo, req.Id); + Storage entity; - if (req.Id.HasValue) - { - entity = await FindEditable(_db.Storages, req.Id.Value, req.Version); - } + if (req.Id.HasValue) entity = await FindEditable(_db.Storages, req.Id.Value, req.Version); else { entity = new Storage(); @@ -82,9 +191,19 @@ public sealed class WmsService entity.Code = req.Code.Trim(); entity.Name = req.Name.Trim(); entity.StorageType = req.StorageType.TrimOr("Storage"); + entity.LocationKind = kind; + entity.ColumnNo = req.ColumnNo; + entity.LevelNo = req.LevelNo <= 0 ? 1 : req.LevelNo; + entity.DepthNo = req.DepthNo <= 0 ? 1 : req.DepthNo; entity.SiteId = req.SiteId.TrimOr(""); - entity.Capacity = req.Capacity; - entity.Status = req.Status.TrimOr(StorageStatuses.Available); + entity.SiteCode = req.SiteCode.TrimOr(entity.SiteId); + entity.Barcode = req.Barcode.TrimOr(""); + entity.Capacity = 1; + var status = StorageStatuses.Normalize(req.Status.TrimOr(StorageStatuses.Empty)); + if (status == StorageStatuses.Disabled || req.Enabled == false) + entity.Status = req.Enabled ? status : StorageStatuses.Disabled; + else if (!string.IsNullOrWhiteSpace(req.Status) && StorageStatuses.All.Contains(status)) + entity.Status = status; entity.Usage = req.Usage.TrimOr(""); entity.Priority = req.Priority; entity.ZoneCode = req.ZoneCode.TrimOr(""); @@ -93,16 +212,86 @@ public sealed class WmsService entity.Enabled = req.Enabled; ApplyCommon(entity, req, actor); await _db.SaveChangesAsync(); + if (entity.Status != StorageStatuses.Disabled) + await SyncOccupancyStatus(storageId: entity.Id); return entity; } - public async Task SaveContainer(MasterDataRequest req, string actor) + public async Task GenerateBins(Guid areaId, GenerateBinsRequest req, string actor) + { + await _refs.EnsureAreaAsync(areaId); + if (req.ColumnFrom > req.ColumnTo || req.LevelFrom > req.LevelTo || req.DepthFrom > req.DepthTo) + throw new InvalidOperationException("生成范围无效"); + var area = await _db.WarehouseAreas.AsNoTracking().FirstAsync(x => x.Id == areaId); + var existing = await _db.Storages.AsNoTracking() + .Where(x => x.AreaId == areaId && x.LocationKind == LocationKinds.Grid) + .Select(x => new { x.ColumnNo, x.LevelNo, x.DepthNo }) + .ToListAsync(); + var set = existing.Select(x => (x.ColumnNo, x.LevelNo, x.DepthNo)).ToHashSet(); + var created = 0; + for (var col = req.ColumnFrom; col <= req.ColumnTo; col++) + for (var level = req.LevelFrom; level <= req.LevelTo; level++) + for (var depth = req.DepthFrom; depth <= req.DepthTo; depth++) + { + if (set.Contains((col, level, depth))) continue; + var code = string.IsNullOrWhiteSpace(req.CodePattern) + ? $"{area.Code}-{level:D2}{col:D2}" + (req.DepthFrom == req.DepthTo && depth == 1 ? "" : $"-{depth}") + : req.CodePattern + .Replace("{Area}", area.Code, StringComparison.OrdinalIgnoreCase) + .Replace("{Level}", level.ToString("D2"), StringComparison.OrdinalIgnoreCase) + .Replace("{Column}", col.ToString("D2"), StringComparison.OrdinalIgnoreCase) + .Replace("{Depth}", depth.ToString(), StringComparison.OrdinalIgnoreCase); + if (await _db.Storages.AnyAsync(x => x.Code == code)) + code = $"{code}-{Guid.NewGuid().ToString("N")[..4]}"; + var entity = new Storage + { + AreaId = areaId, + Code = code, + Name = code, + LocationKind = LocationKinds.Grid, + ColumnNo = col, + LevelNo = level, + DepthNo = depth, + Status = StorageStatuses.Empty, + Capacity = 1, + Enabled = true, + AllowInbound = true, + AllowOutbound = true + }; + StampCreate(entity, actor); + _db.Storages.Add(entity); + created++; + } + await _db.SaveChangesAsync(); + return created; + } + + public async Task SetStorageLock(Guid id, bool isLock, long? version, string actor) + { + var entity = await FindEditable(_db.Storages, id, version); + entity.IsLock = isLock; + entity.UpdatedBy = actor; + await _db.SaveChangesAsync(); + return entity; + } + + public async Task SetStorageEnabled(Guid id, bool enabled, long? version, string actor) + { + var entity = await FindEditable(_db.Storages, id, version); + entity.Enabled = enabled; + entity.Status = enabled + ? (entity.Status == StorageStatuses.Disabled ? StorageStatuses.Empty : entity.Status) + : StorageStatuses.Disabled; + entity.UpdatedBy = actor; + await _db.SaveChangesAsync(); + if (enabled) await SyncOccupancyStatus(storageId: id); + return entity; + } + + public async Task SaveContainer(ContainerRequest req, string actor) { Container entity; - if (req.Id.HasValue) - { - entity = await FindEditable(_db.Containers, req.Id.Value, req.Version); - } + if (req.Id.HasValue) entity = await FindEditable(_db.Containers, req.Id.Value, req.Version); else { entity = new Container(); @@ -110,10 +299,40 @@ public sealed class WmsService _db.Containers.Add(entity); } await EnsureUnique(_db.Containers, x => x.Code == req.Code && x.Id != entity.Id, "容器编码已存在"); + entity.AreaId = req.AreaId; entity.Code = req.Code.Trim(); entity.Name = req.Name.Trim(); entity.ContainerType = req.Type.TrimOr("Box"); - entity.Status = req.Status.TrimOr("Idle"); + var status = req.Status.TrimOr(ContainerStatuses.EmptyMaterial); + entity.Status = ContainerStatuses.All.Contains(status) ? status : ContainerStatuses.EmptyMaterial; + entity.Barcode = req.Barcode.TrimOr(""); + entity.Length = req.Length; + entity.Width = req.Width; + entity.Height = req.Height; + entity.Enabled = req.Enabled; + ApplyCommon(entity, req, actor); + await _db.SaveChangesAsync(); + await SyncOccupancyStatus(containerId: entity.Id); + return entity; + } + + public async Task SaveMaterialType(MaterialTypeRequest req, string actor) + { + MaterialType entity; + if (req.Id.HasValue) entity = await FindEditable(_db.MaterialTypes, req.Id.Value, req.Version); + else + { + entity = new MaterialType(); + StampCreate(entity, actor); + _db.MaterialTypes.Add(entity); + } + await EnsureUnique(_db.MaterialTypes, x => x.Code == req.Code && x.Id != entity.Id, "物料类型编码已存在"); + entity.Code = req.Code.Trim(); + entity.Name = req.Name.Trim(); + entity.Spec = req.Spec.TrimOr(""); + entity.Unit = req.Unit.TrimOr("pcs"); + entity.Category = req.Category.TrimOr(""); + entity.BarcodePrefix = req.BarcodePrefix.TrimOr(""); entity.Enabled = req.Enabled; ApplyCommon(entity, req, actor); await _db.SaveChangesAsync(); @@ -122,11 +341,10 @@ public sealed class WmsService public async Task SaveMaterial(MaterialRequest req, string actor) { + if (!string.IsNullOrWhiteSpace(req.TypeCode)) + await _refs.EnsureMaterialTypeCodeAsync(req.TypeCode); Material entity; - if (req.Id.HasValue) - { - entity = await FindEditable(_db.Materials, req.Id.Value, req.Version); - } + if (req.Id.HasValue) entity = await FindEditable(_db.Materials, req.Id.Value, req.Version); else { entity = new Material(); @@ -134,21 +352,73 @@ public sealed class WmsService _db.Materials.Add(entity); } await EnsureUnique(_db.Materials, x => x.Code == req.Code && x.Id != entity.Id, "物料编码已存在"); + if (!string.IsNullOrWhiteSpace(req.Barcode)) + await EnsureUnique(_db.Materials, x => x.Barcode == req.Barcode && x.Id != entity.Id, "物料条码已存在"); entity.Code = req.Code.Trim(); entity.Name = req.Name.Trim(); + entity.TypeCode = req.TypeCode.TrimOr(""); + entity.Barcode = req.Barcode.TrimOr(""); entity.Spec = req.Spec.TrimOr(""); entity.Unit = req.Unit.TrimOr("pcs"); entity.Category = req.Category.TrimOr(""); + var life = req.LifecycleStatus.TrimOr(MaterialLifecycles.Active); + entity.LifecycleStatus = MaterialLifecycles.All.Contains(life) ? life : MaterialLifecycles.Active; entity.Enabled = req.Enabled; ApplyCommon(entity, req, actor); await _db.SaveChangesAsync(); return entity; } + public async Task ArchiveMaterial(Guid id, long? version, string actor) + { + var entity = await FindEditable(_db.Materials, id, version); + if (await _db.ContainerMaterials.AnyAsync(x => x.MaterialId == id)) + throw new InvalidOperationException("物料仍在绑定中,不能归档"); + entity.LifecycleStatus = MaterialLifecycles.Archived; + entity.UnboundAt ??= DateTimeOffset.UtcNow; + entity.UpdatedBy = actor; + await _db.SaveChangesAsync(); + return entity; + } + + public async Task PurgeArchivedMaterials(string actor, int take = 200) + { + var rows = await _db.Materials + .Where(x => x.LifecycleStatus == MaterialLifecycles.Archived) + .OrderBy(x => x.UpdatedAt) + .Take(take) + .ToListAsync(); + foreach (var entity in rows) + { + if (await _db.ContainerMaterials.AnyAsync(x => x.MaterialId == entity.Id)) continue; + entity.IsDeleted = true; + entity.DeletedAt = DateTimeOffset.UtcNow; + entity.DeletedBy = actor; + entity.UpdatedBy = actor; + } + await _db.SaveChangesAsync(); + } + public async Task DeleteEntity(Guid id, long? version, string actor) where T : EntityBase { var set = _db.Set(); var entity = await FindEditable(set, id, version); + if (typeof(T) == typeof(WarehouseArea)) + { + if (await _db.Storages.AnyAsync(x => x.AreaId == id)) + throw new InvalidOperationException("库区下仍有货位,不能删除"); + } + if (typeof(T) == typeof(Storage)) + { + var sid = id.ToString("D"); + if (await _db.ContainerLocations.AnyAsync(x => x.LocationType == ContainerLocationTypes.Storage && x.LocationId == sid)) + throw new InvalidOperationException("货位仍被容器占用,不能删除"); + } + if (typeof(T) == typeof(Material)) + { + if (await _db.ContainerMaterials.AnyAsync(x => x.MaterialId == id)) + throw new InvalidOperationException("物料仍在绑定中,不能删除"); + } entity.IsDeleted = true; entity.DeletedAt = DateTimeOffset.UtcNow; entity.DeletedBy = actor; @@ -163,9 +433,18 @@ public sealed class WmsService if (!ContainerLocationStatuses.All.Contains(req.Status)) throw new InvalidOperationException("容器位置状态无效"); + Guid? fromStorageId = null; + Guid? toStorageId = null; if (string.Equals(req.LocationType, ContainerLocationTypes.Storage, StringComparison.OrdinalIgnoreCase) && Guid.TryParse(req.LocationId, out var targetStorageId)) { + toStorageId = targetStorageId; + var storage = await _db.Storages.FirstOrDefaultAsync(x => x.Id == targetStorageId) + ?? throw new InvalidOperationException("库位不存在"); + if (!storage.Enabled || storage.Status == StorageStatuses.Disabled || storage.IsLock) + throw new InvalidOperationException("目标库位已停用或锁定"); + if (!storage.AllowInbound) + throw new InvalidOperationException("目标库位不允许入库"); var occupied = await _db.ContainerLocations.AsNoTracking() .AnyAsync(x => x.LocationType == ContainerLocationTypes.Storage && x.LocationId == req.LocationId && @@ -176,6 +455,8 @@ public sealed class WmsService var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == req.ContainerId); var before = current == null ? null : Snapshot(current); + if (before != null && before.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(before.LocationId, out var fs)) + fromStorageId = fs; var now = DateTimeOffset.UtcNow; if (current == null) @@ -217,7 +498,10 @@ public sealed class WmsService Extend = NormalizeExtend(req.Extend) }); + await AddContainerMoveEventAsync(req.ContainerId, fromStorageId, toStorageId, actor, req.Reason.TrimOr(""), now); await _db.SaveChangesAsync(); + await SyncOccupancyStatus(containerId: req.ContainerId, storageId: fromStorageId); + await SyncOccupancyStatus(storageId: toStorageId); return current; } @@ -226,7 +510,11 @@ public sealed class WmsService var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == containerId) ?? throw new InvalidOperationException("容器当前位置不存在"); EnsureUnlocked(current); + Guid? fromStorageId = null; + if (current.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(current.LocationId, out var fs)) + fromStorageId = fs; var before = Snapshot(current); + var now = DateTimeOffset.UtcNow; _db.ContainerLocationHistories.Add(new ContainerLocationHistory { RelationId = current.Id, @@ -237,91 +525,224 @@ public sealed class WmsService BeforeJson = JsonSerializer.Serialize(before, _json), AfterJson = "{}", Operator = actor, - OperatedAt = DateTimeOffset.UtcNow, + OperatedAt = now, Source = "Manual", Reason = reason }); + await AddContainerMoveEventAsync(containerId, fromStorageId, null, actor, reason, now); _db.ContainerLocations.Remove(current); await _db.SaveChangesAsync(); + await SyncOccupancyStatus(containerId: containerId, storageId: fromStorageId); } - public async Task SaveContainerMaterial(ContainerMaterialRequest req, string actor) + public async Task BindMaterial(BindMaterialRequest req, string actor) { await _refs.EnsureContainerAsync(req.ContainerId); - await _refs.EnsureMaterialAsync(req.MaterialId); - if (req.Quantity <= 0) throw new InvalidOperationException("数量必须大于 0"); - if (!ContainerMaterialStatuses.All.Contains(req.Status)) - throw new InvalidOperationException("容器物料状态无效"); + var material = await _db.Materials.FirstOrDefaultAsync(x => x.Id == req.MaterialId) + ?? throw new InvalidOperationException("物料不存在"); + if (material.LifecycleStatus != MaterialLifecycles.Active) + throw new InvalidOperationException("仅 Active 物料可绑定"); + if (await _db.ContainerMaterials.AnyAsync(x => x.MaterialId == req.MaterialId)) + throw new InvalidOperationException("物料已绑定其他容器"); - var current = await _db.ContainerMaterials.FirstOrDefaultAsync(x => - x.ContainerId == req.ContainerId && x.MaterialId == req.MaterialId && - x.BatchNo == req.BatchNo.TrimOr("") && x.SerialNo == req.SerialNo.TrimOr("")); - var before = current == null ? null : Snapshot(current); - - if (current == null) + var now = DateTimeOffset.UtcNow; + var current = new ContainerMaterial { - current = new ContainerMaterial { ContainerId = req.ContainerId, MaterialId = req.MaterialId, LoadedAt = req.LoadedAt ?? DateTimeOffset.UtcNow }; - StampCreate(current, actor); - _db.ContainerMaterials.Add(current); - } - else - { - EnsureVersion(current, req.Version); - EnsureUnlocked(current); - } - - var oldQty = current.Quantity; - current.Quantity = req.Quantity; - current.BatchNo = req.BatchNo.TrimOr(""); - current.SerialNo = req.SerialNo.TrimOr(""); - current.Status = req.Status; - current.LoadedAt = req.LoadedAt ?? current.LoadedAt; - current.UnloadedAt = req.UnloadedAt; + ContainerId = req.ContainerId, + MaterialId = req.MaterialId, + Quantity = 1, + Status = ContainerMaterialStatuses.Bound, + BoundAt = now, + LoadedAt = now + }; + StampCreate(current, actor); ApplyCommon(current, req, actor); + _db.ContainerMaterials.Add(current); _db.ContainerMaterialHistories.Add(new ContainerMaterialHistory { RelationId = current.Id, ContainerId = current.ContainerId, MaterialId = current.MaterialId, - EventType = before == null ? "Load" : "Adjust", - QuantityDelta = current.Quantity - oldQty, - BeforeJson = before == null ? "{}" : JsonSerializer.Serialize(before, _json), + EventType = "Bind", + QuantityDelta = 0, + BeforeJson = "{}", AfterJson = JsonSerializer.Serialize(Snapshot(current), _json), Operator = actor, - OperatedAt = DateTimeOffset.UtcNow, + OperatedAt = now, Source = req.Source.TrimOr("Manual"), Reason = req.Reason.TrimOr(""), Remark = req.Remark.TrimOr(""), Extend = NormalizeExtend(req.Extend) }); + material.UnboundAt = null; + await AddBindUnbindEventAsync(StockEventTypes.Bind, material, req.ContainerId, actor, req.Reason.TrimOr(""), now); await _db.SaveChangesAsync(); + await SyncOccupancyStatus(containerId: req.ContainerId); return current; } - public async Task UnloadMaterial(Guid id, string actor, string reason = "") + /// 兼容旧接口:忽略数量,按实体绑定。 + public Task SaveContainerMaterial(ContainerMaterialRequest req, string actor) => + BindMaterial(new BindMaterialRequest(req.Id, req.Version, req.ContainerId, req.MaterialId, req.Source, req.Reason, req.IsLock, req.Remark, req.Extend), actor); + + public async Task UnbindMaterial(Guid bindingId, string actor, string reason = "", bool archive = false) { - var current = await _db.ContainerMaterials.FirstOrDefaultAsync(x => x.Id == id) - ?? throw new InvalidOperationException("容器物料不存在"); + var current = await _db.ContainerMaterials.FirstOrDefaultAsync(x => x.Id == bindingId) + ?? throw new InvalidOperationException("绑定不存在"); EnsureUnlocked(current); + var material = await _db.Materials.FirstOrDefaultAsync(x => x.Id == current.MaterialId); var before = Snapshot(current); + var now = DateTimeOffset.UtcNow; + var containerId = current.ContainerId; + _db.ContainerMaterialHistories.Add(new ContainerMaterialHistory { RelationId = current.Id, ContainerId = current.ContainerId, MaterialId = current.MaterialId, - EventType = "Unload", - QuantityDelta = -current.Quantity, + EventType = "Unbind", + QuantityDelta = 0, BeforeJson = JsonSerializer.Serialize(before, _json), AfterJson = "{}", Operator = actor, - OperatedAt = DateTimeOffset.UtcNow, + OperatedAt = now, Source = "Manual", Reason = reason }); + + if (material != null) + { + material.UnboundAt = now; + if (archive) material.LifecycleStatus = MaterialLifecycles.Archived; + await AddBindUnbindEventAsync(StockEventTypes.Unbind, material, containerId, actor, reason, now); + } + _db.ContainerMaterials.Remove(current); await _db.SaveChangesAsync(); + await SyncOccupancyStatus(containerId: containerId); + } + + public Task UnloadMaterial(Guid id, string actor, string reason = "") => UnbindMaterial(id, actor, reason); + + public async Task SyncOccupancyStatus(Guid? storageId = null, Guid? containerId = null) + { + var containerIds = new HashSet(); + if (containerId.HasValue) containerIds.Add(containerId.Value); + + if (storageId.HasValue) + { + var sid = storageId.Value.ToString("D"); + var loc = await _db.ContainerLocations.FirstOrDefaultAsync(x => + x.LocationType == ContainerLocationTypes.Storage && x.LocationId == sid); + if (loc != null) containerIds.Add(loc.ContainerId); + + var storage = await _db.Storages.FirstOrDefaultAsync(x => x.Id == storageId.Value); + if (storage != null && storage.Status != StorageStatuses.Disabled) + { + if (loc == null) storage.Status = StorageStatuses.Empty; + else + { + var hasMat = await _db.ContainerMaterials.AnyAsync(x => x.ContainerId == loc.ContainerId); + storage.Status = hasMat ? StorageStatuses.FullContainer : StorageStatuses.EmptyContainer; + } + } + } + + foreach (var cid in containerIds) + { + var ctn = await _db.Containers.FirstOrDefaultAsync(x => x.Id == cid); + if (ctn == null) continue; + var hasMat = await _db.ContainerMaterials.AnyAsync(x => x.ContainerId == cid); + ctn.Status = hasMat ? ContainerStatuses.FullMaterial : ContainerStatuses.EmptyMaterial; + + var loc = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == cid); + if (loc != null && loc.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(loc.LocationId, out var sid2)) + { + var storage = await _db.Storages.FirstOrDefaultAsync(x => x.Id == sid2); + if (storage != null && storage.Status != StorageStatuses.Disabled) + storage.Status = hasMat ? StorageStatuses.FullContainer : StorageStatuses.EmptyContainer; + } + } + + await _db.SaveChangesAsync(); + } + + public async Task EnsureDefaultWarehouseAsync(string actor = "system") + { + var existing = await _db.Warehouses.FirstOrDefaultAsync(x => x.Code == WmsDefaults.DefaultWarehouseCode); + if (existing != null) return existing; + var wh = new Warehouse + { + Code = WmsDefaults.DefaultWarehouseCode, + Name = WmsDefaults.DefaultWarehouseName, + Type = "Default", + Enabled = true, + SortOrder = 0 + }; + StampCreate(wh, actor); + _db.Warehouses.Add(wh); + await _db.SaveChangesAsync(); + return wh; + } + + public async Task MigrateLegacyAsync(string actor = "system") + { + var wh = await EnsureDefaultWarehouseAsync(actor); + var areas = await _db.WarehouseAreas.Where(x => x.WarehouseId == Guid.Empty).ToListAsync(); + foreach (var a in areas) + { + a.WarehouseId = wh.Id; + if (string.IsNullOrWhiteSpace(a.LayoutMode)) a.LayoutMode = AreaLayoutModes.Flat; + } + + var storages = await _db.Storages.ToListAsync(); + foreach (var s in storages) + { + if (string.IsNullOrWhiteSpace(s.LocationKind)) + s.LocationKind = LocationKinds.Station; + if (s.LevelNo <= 0) s.LevelNo = 1; + if (s.DepthNo <= 0) s.DepthNo = 1; + if (string.IsNullOrWhiteSpace(s.SiteCode)) s.SiteCode = s.SiteId; + s.Status = StorageStatuses.Normalize(s.Status) switch + { + StorageStatuses.Available or StorageStatuses.Idle => StorageStatuses.Empty, + StorageStatuses.Occupied => StorageStatuses.FullContainer, + var x => x + }; + if (!s.Enabled) s.Status = StorageStatuses.Disabled; + } + + var containers = await _db.Containers.ToListAsync(); + foreach (var c in containers) + { + c.Status = c.Status switch + { + "Idle" or "Empty" => ContainerStatuses.EmptyMaterial, + "Loaded" => ContainerStatuses.FullMaterial, + _ when ContainerStatuses.All.Contains(c.Status) => c.Status, + _ => ContainerStatuses.EmptyMaterial + }; + } + + var materials = await _db.Materials.Where(x => string.IsNullOrWhiteSpace(x.LifecycleStatus)).ToListAsync(); + foreach (var m in materials) + m.LifecycleStatus = MaterialLifecycles.Active; + + var binds = await _db.ContainerMaterials.ToListAsync(); + foreach (var b in binds) + { + b.Quantity = 1; + if (b.BoundAt == default) b.BoundAt = b.LoadedAt == default ? DateTimeOffset.UtcNow : b.LoadedAt; + if (!ContainerMaterialStatuses.ActiveBind.Contains(b.Status)) + b.Status = ContainerMaterialStatuses.Bound; + } + + await _db.SaveChangesAsync(); + + foreach (var s in storages.Where(x => x.Status != StorageStatuses.Disabled)) + await SyncOccupancyStatus(storageId: s.Id); } public async Task> LocationHistory(Guid? containerId = null) @@ -330,7 +751,6 @@ public sealed class WmsService ? _db.ContainerLocationHistories.AsNoTracking().Where(x => x.ContainerId == containerId.Value) : _db.ContainerLocationHistories.AsNoTracking()) .ToListAsync(); - return rows.OrderByDescending(x => x.OperatedAt).Take(500).ToList(); } @@ -339,11 +759,90 @@ public sealed class WmsService var query = _db.ContainerMaterialHistories.AsNoTracking().AsQueryable(); if (containerId.HasValue) query = query.Where(x => x.ContainerId == containerId.Value); if (materialId.HasValue) query = query.Where(x => x.MaterialId == materialId.Value); - var rows = await query.ToListAsync(); return rows.OrderByDescending(x => x.OperatedAt).Take(500).ToList(); } + private async Task EnsureGridCoordUnique(Guid areaId, int column, int level, int depth, Guid? excludeId) + { + var exists = await _db.Storages.AnyAsync(x => + x.AreaId == areaId && + x.LocationKind == LocationKinds.Grid && + x.ColumnNo == column && x.LevelNo == level && x.DepthNo == depth && + (!excludeId.HasValue || x.Id != excludeId.Value)); + if (exists) throw new InvalidOperationException("同库区网格坐标已存在"); + } + + private async Task AddBindUnbindEventAsync(string eventType, Material material, Guid containerId, string actor, string reason, DateTimeOffset now) + { + var ctn = await _db.Containers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == containerId); + var loc = await _db.ContainerLocations.AsNoTracking().FirstOrDefaultAsync(x => x.ContainerId == containerId); + Storage? storage = null; + WarehouseArea? area = null; + if (loc is { LocationType: ContainerLocationTypes.Storage } && Guid.TryParse(loc.LocationId, out var sid)) + { + storage = await _db.Storages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == sid); + if (storage != null) + area = await _db.WarehouseAreas.AsNoTracking().FirstOrDefaultAsync(x => x.Id == storage.AreaId); + } + + if (string.IsNullOrWhiteSpace(material.Code)) + throw new InvalidOperationException("物料编码快照不能为空"); + + _db.StockEvents.Add(new StockEvent + { + EventType = eventType, + MaterialId = material.Id, + MaterialCode = material.Code, + MaterialName = material.Name, + MaterialBarcode = material.Barcode, + MaterialTypeCode = material.TypeCode, + ContainerId = containerId, + ContainerCode = ctn?.Code ?? "", + ContainerName = ctn?.Name ?? "", + StorageId = storage?.Id, + StorageCode = storage?.Code ?? loc?.LocationCode ?? "", + StorageName = storage?.Name ?? loc?.LocationName ?? "", + AreaCode = area?.Code ?? "", + RefType = "Manual", + Operator = actor, + OperatedAt = now, + Reason = reason + }); + } + + private async Task AddContainerMoveEventAsync(Guid containerId, Guid? fromStorageId, Guid? toStorageId, string actor, string reason, DateTimeOffset now) + { + var ctn = await _db.Containers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == containerId) + ?? throw new InvalidOperationException("容器不存在"); + Storage? from = null, to = null; + if (fromStorageId.HasValue) + from = await _db.Storages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == fromStorageId.Value); + if (toStorageId.HasValue) + to = await _db.Storages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == toStorageId.Value); + + _db.StockEvents.Add(new StockEvent + { + EventType = StockEventTypes.ContainerMove, + ContainerId = containerId, + ContainerCode = ctn.Code, + ContainerName = ctn.Name, + FromStorageId = from?.Id, + FromStorageCode = from?.Code ?? "", + FromStorageName = from?.Name ?? "", + ToStorageId = to?.Id, + ToStorageCode = to?.Code ?? "", + ToStorageName = to?.Name ?? "", + StorageId = to?.Id ?? from?.Id, + StorageCode = to?.Code ?? from?.Code ?? "", + StorageName = to?.Name ?? from?.Name ?? "", + RefType = "Manual", + Operator = actor, + OperatedAt = now, + Reason = reason + }); + } + private async Task FindEditable(DbSet set, Guid id, long? version) where T : EntityBase { var entity = await set.FirstOrDefaultAsync(x => x.Id == id) ?? throw new InvalidOperationException("数据不存在"); @@ -397,10 +896,12 @@ public sealed class WmsService var s = q.Trim(); return typeof(T).Name switch { + nameof(Warehouse) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s)), nameof(WarehouseArea) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s)), - nameof(Storage) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.SiteId.Contains(s)), - nameof(Container) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s)), - nameof(Material) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.Spec.Contains(s)), + nameof(Storage) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.SiteId.Contains(s) || x.Barcode.Contains(s)), + nameof(Container) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.Barcode.Contains(s)), + nameof(MaterialType) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s)), + nameof(Material) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.Spec.Contains(s) || x.Barcode.Contains(s)), nameof(ContainerLocation) => (IQueryable)((IQueryable)query).Where(x => x.LocationCode.Contains(s) || x.LocationName.Contains(s)), nameof(ContainerMaterial) => query, _ => query @@ -411,115 +912,69 @@ public sealed class WmsService x.Id, x.ContainerId, x.LocationType, x.LocationId, x.LocationCode, x.LocationName, x.Status, x.EnteredAt, x.Version); private static ContainerMaterialSnapshot Snapshot(ContainerMaterial x) => new( - x.Id, x.ContainerId, x.MaterialId, x.Quantity, x.BatchNo, x.SerialNo, x.Status, x.LoadedAt, x.UnloadedAt, x.Version); + x.Id, x.ContainerId, x.MaterialId, x.Quantity, x.BatchNo, x.SerialNo, x.Status, 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); + 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 LoadedAt, - DateTimeOffset? UnloadedAt, - long Version); + 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 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); + 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 SiteId, - 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); + 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 Spec, - string Unit, - string Category, - bool Enabled, - bool IsLock, - string Remark, - string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend); + 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); + 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); + 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 { diff --git a/frontends/apps/simple-platform-vue/src/api/wms.ts b/frontends/apps/simple-platform-vue/src/api/wms.ts index 8b1c2ea..85db8a4 100644 --- a/frontends/apps/simple-platform-vue/src/api/wms.ts +++ b/frontends/apps/simple-platform-vue/src/api/wms.ts @@ -1,8 +1,10 @@ import http from './http' import type { - WarehouseArea, Storage, Container, Material, + Warehouse, WarehouseArea, Storage, Container, Material, MaterialType, ContainerLocation, ContainerMaterial, ContainerLocationHistory, ContainerMaterialHistory, - MasterDataPayload, StoragePayload, MaterialPayload, ContainerLocationPayload, ContainerMaterialPayload, + InventoryMaterialRow, StockEvent, + MasterDataPayload, AreaPayload, StoragePayload, ContainerPayload, MaterialPayload, MaterialTypePayload, + ContainerLocationPayload, BindMaterialPayload, GenerateBinsPayload, WmsTransportRule, WmsTransportTask, TransportRulePayload, TransportRequestQuery, TransportCandidatePreview } from '@/types/wms' @@ -10,11 +12,25 @@ function q(params?: T) { return { params } } -export async function listAreas(keyword?: string) { - const { data } = await http.get('/wms/areas', q({ q: keyword })) +export async function listWarehouses(keyword?: string) { + const { data } = await http.get('/wms/warehouses', q({ q: keyword })) return data } -export async function saveArea(payload: MasterDataPayload) { +export async function saveWarehouse(payload: MasterDataPayload) { + const { data } = payload.id + ? await http.put(`/wms/warehouses/${payload.id}`, payload) + : await http.post('/wms/warehouses', payload) + return data +} +export async function deleteWarehouse(id: string, version?: number) { + await http.delete(`/wms/warehouses/${id}`, q({ version })) +} + +export async function listAreas(keyword?: string, warehouseId?: string) { + const { data } = await http.get('/wms/areas', q({ q: keyword, warehouseId })) + return data +} +export async function saveArea(payload: AreaPayload) { const { data } = payload.id ? await http.put(`/wms/areas/${payload.id}`, payload) : await http.post('/wms/areas', payload) @@ -24,8 +40,8 @@ export async function deleteArea(id: string, version?: number) { await http.delete(`/wms/areas/${id}`, q({ version })) } -export async function listStorages(keyword?: string) { - const { data } = await http.get('/wms/storages', q({ q: keyword })) +export async function listStorages(keyword?: string, areaId?: string, locationKind?: string) { + const { data } = await http.get('/wms/storages', q({ q: keyword, areaId, locationKind })) return data } export async function saveStorage(payload: StoragePayload) { @@ -37,12 +53,32 @@ export async function saveStorage(payload: StoragePayload) { export async function deleteStorage(id: string, version?: number) { await http.delete(`/wms/storages/${id}`, q({ version })) } +export async function generateBins(areaId: string, payload: GenerateBinsPayload) { + const { data } = await http.post<{ created: number }>(`/wms/areas/${areaId}/generate-bins`, payload) + return data +} +export async function lockStorage(id: string, version?: number) { + const { data } = await http.post(`/wms/storages/${id}/lock`, null, q({ version })) + return data +} +export async function unlockStorage(id: string, version?: number) { + const { data } = await http.post(`/wms/storages/${id}/unlock`, null, q({ version })) + return data +} +export async function enableStorage(id: string, version?: number) { + const { data } = await http.post(`/wms/storages/${id}/enable`, null, q({ version })) + return data +} +export async function disableStorage(id: string, version?: number) { + const { data } = await http.post(`/wms/storages/${id}/disable`, null, q({ version })) + return data +} export async function listContainers(keyword?: string) { const { data } = await http.get('/wms/containers', q({ q: keyword })) return data } -export async function saveContainer(payload: MasterDataPayload) { +export async function saveContainer(payload: ContainerPayload) { const { data } = payload.id ? await http.put(`/wms/containers/${payload.id}`, payload) : await http.post('/wms/containers', payload) @@ -52,8 +88,22 @@ export async function deleteContainer(id: string, version?: number) { await http.delete(`/wms/containers/${id}`, q({ version })) } -export async function listMaterials(keyword?: string) { - const { data } = await http.get('/wms/materials', q({ q: keyword })) +export async function listMaterialTypes(keyword?: string) { + const { data } = await http.get('/wms/material-types', q({ q: keyword })) + return data +} +export async function saveMaterialType(payload: MaterialTypePayload) { + const { data } = payload.id + ? await http.put(`/wms/material-types/${payload.id}`, payload) + : await http.post('/wms/material-types', payload) + return data +} +export async function deleteMaterialType(id: string, version?: number) { + await http.delete(`/wms/material-types/${id}`, q({ version })) +} + +export async function listMaterials(params?: { q?: string; lifecycle?: string; onlyUnbound?: boolean; onlyBound?: boolean }) { + const { data } = await http.get('/wms/materials', q(params)) return data } export async function saveMaterial(payload: MaterialPayload) { @@ -65,6 +115,13 @@ export async function saveMaterial(payload: MaterialPayload) { export async function deleteMaterial(id: string, version?: number) { await http.delete(`/wms/materials/${id}`, q({ version })) } +export async function archiveMaterial(id: string, version?: number) { + const { data } = await http.post(`/wms/materials/${id}/archive`, null, q({ version })) + return data +} +export async function purgeArchivedMaterials(take = 200) { + await http.post('/wms/materials/purge-archived', null, q({ take })) +} export async function listContainerLocations(params?: { locationType?: string; q?: string }) { const { data } = await http.get('/wms/container-locations', q(params)) @@ -78,18 +135,29 @@ export async function deleteContainerLocation(containerId: string, reason?: stri await http.delete(`/wms/container-locations/${containerId}`, q({ reason })) } -export async function listContainerMaterials(keyword?: string) { - const { data } = await http.get('/wms/container-materials', q({ q: keyword })) +export async function listContainerMaterials(params?: { q?: string; containerId?: string }) { + const { data } = await http.get('/wms/container-materials', q(params)) return data } -export async function saveContainerMaterial(payload: ContainerMaterialPayload) { - const { data } = payload.id - ? await http.put(`/wms/container-materials/${payload.id}`, payload) - : await http.post('/wms/container-materials', payload) +export async function bindMaterial(payload: BindMaterialPayload) { + const { data } = await http.post('/wms/container-materials/bind', payload) return data } -export async function deleteContainerMaterial(id: string, reason?: string) { - await http.delete(`/wms/container-materials/${id}`, q({ reason })) +/** @deprecated 使用 bindMaterial */ +export async function saveContainerMaterial(payload: BindMaterialPayload) { + return bindMaterial(payload) +} +export async function deleteContainerMaterial(id: string, reason?: string, archive = false) { + await http.delete(`/wms/container-materials/${id}`, q({ reason, archive })) +} + +export async function listInventoryMaterials(params?: { areaId?: string; storageId?: string; q?: string }) { + const { data } = await http.get('/wms/inventory/materials', q(params)) + return data +} +export async function listStockEvents(params?: { eventType?: string; materialId?: string; containerId?: string }) { + const { data } = await http.get('/wms/stock-events', q(params)) + return data } export async function listContainerLocationHistory(containerId?: string) { diff --git a/frontends/apps/simple-platform-vue/src/types/wms.ts b/frontends/apps/simple-platform-vue/src/types/wms.ts index cba2db2..7e664a5 100644 --- a/frontends/apps/simple-platform-vue/src/types/wms.ts +++ b/frontends/apps/simple-platform-vue/src/types/wms.ts @@ -13,7 +13,7 @@ export interface EntityBase { extend: string } -export interface WarehouseArea extends EntityBase { +export interface Warehouse extends EntityBase { code: string name: string type: string @@ -21,12 +21,29 @@ export interface WarehouseArea extends EntityBase { sortOrder: number } +export interface WarehouseArea extends EntityBase { + warehouseId: string + code: string + name: string + type: string + layoutMode: string + state: string + enabled: boolean + sortOrder: number +} + export interface Storage extends EntityBase { areaId: string code: string name: string storageType: string + locationKind: string + columnNo: number + levelNo: number + depthNo: number siteId: string + siteCode: string + barcode: string capacity: number status: string usage: string @@ -38,25 +55,43 @@ export interface Storage extends EntityBase { } export interface Container extends EntityBase { + areaId?: string code: string name: string containerType: string status: string + barcode: string + length: number + width: number + height: number + enabled: boolean +} + +export interface MaterialType extends EntityBase { + code: string + name: string + spec: string + unit: string + category: string + barcodePrefix: string enabled: boolean } export interface Material extends EntityBase { code: string name: string + typeCode: string + barcode: string spec: string unit: string category: string + lifecycleStatus: string + unboundAt?: string enabled: boolean } export type ContainerLocationType = 'Storage' | 'Car' export type ContainerLocationStatus = 'Active' | 'Locked' | 'Exception' -export type ContainerMaterialStatus = 'Loaded' | 'Unloaded' | 'Adjusted' | 'Frozen' export interface ContainerLocation extends EntityBase { containerId: string @@ -71,14 +106,64 @@ export interface ContainerLocation extends EntityBase { export interface ContainerMaterial extends EntityBase { containerId: string materialId: string + /** @deprecated 绑定语义固定为 1,UI 不展示数量 */ quantity: number batchNo: string serialNo: string - status: ContainerMaterialStatus + status: string + boundAt: string loadedAt: string unloadedAt?: string } +export interface InventoryMaterialRow { + materialId: string + materialCode: string + materialName: string + materialBarcode: string + typeCode: string + containerId: string + containerCode: string + containerName: string + storageId?: string + storageCode: string + storageName: string + areaId?: string + areaCode: string + areaName: string + locationType: string + boundAt: string +} + +export interface StockEvent { + id: string + eventType: string + materialId?: string + materialCode: string + materialName: string + materialBarcode: string + materialTypeCode: string + containerId?: string + containerCode: string + containerName: string + storageId?: string + storageCode: string + storageName: string + areaCode: string + fromStorageId?: string + fromStorageCode: string + fromStorageName: string + toStorageId?: string + toStorageCode: string + toStorageName: string + refType: string + refId?: string + refCode: string + operator: string + operatedAt: string + reason: string +} + export interface ContainerLocationHistory { id: string relationId?: string @@ -129,6 +214,22 @@ export interface MasterDataPayload { extend: string } +export interface AreaPayload { + id?: string + version?: number + warehouseId?: string + code: string + name: string + type: string + layoutMode: string + state: string + enabled: boolean + sortOrder: number + isLock: boolean + remark: string + extend: string +} + export interface StoragePayload { id?: string version?: number @@ -136,7 +237,13 @@ export interface StoragePayload { code: string name: string storageType: string + locationKind: string + columnNo: number + levelNo: number + depthNo: number siteId: string + siteCode: string + barcode: string capacity: number status: string usage: string @@ -150,7 +257,25 @@ export interface StoragePayload { extend: string } -export interface MaterialPayload { +export interface ContainerPayload { + id?: string + version?: number + areaId?: string + code: string + name: string + type: string + status: string + barcode: string + length: number + width: number + height: number + enabled: boolean + isLock: boolean + remark: string + extend: string +} + +export interface MaterialTypePayload { id?: string version?: number code: string @@ -158,6 +283,24 @@ export interface MaterialPayload { spec: string unit: string category: string + barcodePrefix: string + enabled: boolean + isLock: boolean + remark: string + extend: string +} + +export interface MaterialPayload { + id?: string + version?: number + code: string + name: string + typeCode: string + barcode: string + spec: string + unit: string + category: string + lifecycleStatus: string enabled: boolean isLock: boolean remark: string @@ -179,17 +322,11 @@ export interface ContainerLocationPayload { extend: string } -export interface ContainerMaterialPayload { +export interface BindMaterialPayload { id?: string version?: number containerId: string materialId: string - quantity: number - batchNo: string - serialNo: string - status: ContainerMaterialStatus - loadedAt?: string - unloadedAt?: string source: string reason: string isLock: boolean @@ -197,6 +334,16 @@ export interface ContainerMaterialPayload { extend: string } +export interface GenerateBinsPayload { + columnFrom: number + columnTo: number + levelFrom: number + levelTo: number + depthFrom: number + depthTo: number + codePattern?: string +} + export type WmsTransportTriggerType = 'MaterialCall' | 'FinishedGoodsOffline' | 'AutoTransfer' export type WmsTransportTaskStatus = 'Pending' | 'Reserved' | 'Dispatched' | 'InTransit' | 'Completed' | 'Failed' | 'Cancelled' diff --git a/frontends/apps/simple-platform-vue/src/views/admin/WarehouseManagementView.vue b/frontends/apps/simple-platform-vue/src/views/admin/WarehouseManagementView.vue index 6056157..12794ee 100644 --- a/frontends/apps/simple-platform-vue/src/views/admin/WarehouseManagementView.vue +++ b/frontends/apps/simple-platform-vue/src/views/admin/WarehouseManagementView.vue @@ -5,7 +5,7 @@ @@ -25,6 +25,7 @@ + @@ -37,6 +38,7 @@
新增库位 + 批量生成网格 批量删除 ({{ batchStorages.batchCount }})
@@ -46,7 +48,9 @@ - + + + @@ -76,9 +80,31 @@ + +
+ + 新增类型 + 批量删除 ({{ batchMaterialTypes.batchCount }}) +
+ + + + + + + + + + + +
+
+ + + 新增物料 批量删除 ({{ batchMaterials.batchCount }})
@@ -86,13 +112,25 @@ + + - + - + + +
+ + + + @@ -120,18 +158,50 @@
- 装料/调整 - 批量卸料 ({{ batchContainerMaterials.batchCount }}) + 绑定物料 + 批量解绑 ({{ batchContainerMaterials.batchCount }})
- - - - - - + + + + + + + +
+ + +
+ + 刷新 +
+ + + + + + + + +
+ + + + + + + + + + + + @@ -177,7 +247,6 @@ - 预览候选生成任务 @@ -187,7 +256,6 @@ - @@ -225,21 +293,52 @@ + + + + + + + + + ~ + + + + + ~ + + + + + ~ + + + + + @@ -249,16 +348,17 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { Refresh } from '@element-plus/icons-vue' import * as wmsApi from '@/api/wms' import WmsEntityDialog, { type DialogKind } from '@/views/admin/warehouse/WmsEntityDialog.vue' +import AreaFacadePanel from '@/views/admin/warehouse/AreaFacadePanel.vue' import { useWmsBatchActions } from '@/views/admin/warehouse/useWmsBatchActions' import { - STORAGE_TYPES, STORAGE_STATUSES, CONTAINER_TYPES, CONTAINER_STATUSES, - CONTAINER_LOCATION_STATUSES, CONTAINER_MATERIAL_STATUSES, + AREA_LAYOUT_MODES, STORAGE_TYPES, STORAGE_STATUSES, STORAGE_STATUS_TYPE, LOCATION_KINDS, + CONTAINER_TYPES, CONTAINER_STATUSES, CONTAINER_LOCATION_STATUSES, MATERIAL_LIFECYCLES, TRANSPORT_TRIGGER_LABELS, TRANSPORT_TASK_STATUS_TYPE, labelOf, truncate } from '@/views/admin/warehouse/wmsOptions' import type { - WarehouseArea, Storage, Container, Material, ContainerLocation, ContainerMaterial, - ContainerLocationHistory, ContainerMaterialHistory, MasterDataPayload, StoragePayload, - MaterialPayload, ContainerLocationPayload, ContainerMaterialPayload, + WarehouseArea, Storage, Container, Material, MaterialType, ContainerLocation, ContainerMaterial, + ContainerLocationHistory, ContainerMaterialHistory, InventoryMaterialRow, StockEvent, + MasterDataPayload, StoragePayload, MaterialPayload, MaterialTypePayload, ContainerLocationPayload, BindMaterialPayload, WmsTransportRule, WmsTransportTask, TransportRulePayload, TransportRequestQuery, TransportCandidatePreview } from '@/types/wms' @@ -270,19 +370,28 @@ const keyword = ref('') const locationFilter = ref('') const ruleTriggerFilter = ref('') const taskStatusFilter = ref('') +const materialLifecycleFilter = ref('Active') const previewLoading = ref(false) const taskStatuses = ['Pending', 'Reserved', 'Dispatched', 'InTransit', 'Completed', 'Failed', 'Cancelled'] const loading = ref(false) const saving = ref(false) const dialogVisible = ref(false) const dialogKind = ref('area') +const areaLayoutMode = ref('Flat') +const containerBarcode = ref('') +const generateBinsVisible = ref(false) +const generateBinsAreaId = ref('') +const generateBinsForm = reactive({ columnFrom: 1, columnTo: 5, levelFrom: 1, levelTo: 3, depthFrom: 1, depthTo: 1 }) const areas = ref([]) const storages = ref([]) const containers = ref([]) +const materialTypes = ref([]) const materials = ref([]) const containerLocations = ref([]) const containerMaterials = ref([]) +const inventoryRows = ref([]) +const stockEvents = ref([]) const locationHistory = ref([]) const materialHistory = ref([]) const transportRules = ref([]) @@ -292,14 +401,16 @@ const previewResult = ref(null) const masterForm = reactive(baseMaster()) const storageForm = reactive(baseStorage()) const materialForm = reactive(baseMaterial()) +const materialTypeForm = reactive(baseMaterialType()) const locationForm = reactive(baseLocation()) -const containerMaterialForm = reactive(baseContainerMaterial()) +const containerMaterialForm = reactive(baseContainerMaterial()) const ruleForm = reactive(baseRule()) const previewForm = reactive({ triggerType: 'MaterialCall', priority: 0, reason: '' }) const batchAreas = useWmsBatchActions() const batchStorages = useWmsBatchActions() const batchContainers = useWmsBatchActions() +const batchMaterialTypes = useWmsBatchActions() const batchMaterials = useWmsBatchActions() const batchRules = useWmsBatchActions() const batchLocations = useWmsBatchActions() @@ -317,11 +428,18 @@ const occupiedStorageIds = computed(() => { const filteredAreas = computed(() => filterRows(areas.value, keyword.value)) const filteredStorages = computed(() => filterRows(storages.value, keyword.value)) const filteredContainers = computed(() => filterRows(containers.value, keyword.value)) +const filteredMaterialTypes = computed(() => filterRows(materialTypes.value, keyword.value)) const filteredMaterials = computed(() => filterRows(materials.value, keyword.value)) const filteredContainerLocations = computed(() => containerLocations.value.filter((r) => (!locationFilter.value || r.locationType === locationFilter.value) && (!keyword.value || `${r.locationCode} ${r.locationName}`.toLowerCase().includes(keyword.value.toLowerCase())))) const filteredContainerMaterials = computed(() => filterRows(containerMaterials.value, keyword.value)) +const filteredInventory = computed(() => { + if (!keyword.value) return inventoryRows.value + const s = keyword.value.toLowerCase() + return inventoryRows.value.filter((r) => + `${r.materialCode} ${r.materialName} ${r.containerCode} ${r.storageCode} ${r.areaCode}`.toLowerCase().includes(s)) +}) const filteredRules = computed(() => transportRules.value.filter((r) => !ruleTriggerFilter.value || r.triggerType === ruleTriggerFilter.value)) const historyRows = computed(() => [ ...locationHistory.value.map((x) => ({ ...x, kind: '位置' })), @@ -333,15 +451,24 @@ function taskSelectable(row: WmsTransportTask) { return !row.isLock && ['Pending', 'Reserved', 'Failed'].includes(row.status) } +async function reloadMaterials() { + materials.value = await wmsApi.listMaterials({ + lifecycle: materialLifecycleFilter.value || undefined + }) +} + async function loadAll() { loading.value = true try { - const [a, s, c, m, cl, cm, lh, mh] = await Promise.all([ - wmsApi.listAreas(), wmsApi.listStorages(), wmsApi.listContainers(), wmsApi.listMaterials(), - wmsApi.listContainerLocations(), wmsApi.listContainerMaterials(), wmsApi.listContainerLocationHistory(), wmsApi.listContainerMaterialHistory() + const [a, s, c, mt, m, cl, cm, lh, mh, inv, ev] = await Promise.all([ + wmsApi.listAreas(), wmsApi.listStorages(), wmsApi.listContainers(), wmsApi.listMaterialTypes(), + wmsApi.listMaterials({ lifecycle: materialLifecycleFilter.value || undefined }), + wmsApi.listContainerLocations(), wmsApi.listContainerMaterials(), wmsApi.listContainerLocationHistory(), wmsApi.listContainerMaterialHistory(), + wmsApi.listInventoryMaterials(), wmsApi.listStockEvents() ]) - areas.value = a; storages.value = s; containers.value = c; materials.value = m + areas.value = a; storages.value = s; containers.value = c; materialTypes.value = mt; materials.value = m containerLocations.value = cl; containerMaterials.value = cm; locationHistory.value = lh; materialHistory.value = mh + inventoryRows.value = inv; stockEvents.value = ev await loadTransportRules() await loadTransportTasks() } finally { @@ -349,18 +476,47 @@ async function loadAll() { } } -function openArea(row?: WarehouseArea) { dialogKind.value = 'area'; Object.assign(masterForm, baseMaster(row)); dialogVisible.value = true } +async function loadInventory() { + inventoryRows.value = await wmsApi.listInventoryMaterials() + stockEvents.value = await wmsApi.listStockEvents() +} + +function openArea(row?: WarehouseArea) { + dialogKind.value = 'area' + Object.assign(masterForm, baseMaster(row)) + areaLayoutMode.value = row?.layoutMode ?? 'Flat' + dialogVisible.value = true +} function openStorage(row?: Storage) { dialogKind.value = 'storage'; Object.assign(storageForm, baseStorage(row)); dialogVisible.value = true } function openContainer(row?: Container) { dialogKind.value = 'container' Object.assign(masterForm, baseMaster(row)) if (row) masterForm.type = row.containerType + containerBarcode.value = row?.barcode ?? '' dialogVisible.value = true } +function openMaterialType(row?: MaterialType) { dialogKind.value = 'materialType'; Object.assign(materialTypeForm, baseMaterialType(row)); dialogVisible.value = true } function openMaterial(row?: Material) { dialogKind.value = 'material'; Object.assign(materialForm, baseMaterial(row)); dialogVisible.value = true } function openLocation(row?: ContainerLocation) { dialogKind.value = 'location'; Object.assign(locationForm, baseLocation(row)); dialogVisible.value = true } function openContainerMaterial(row?: ContainerMaterial) { dialogKind.value = 'containerMaterial'; Object.assign(containerMaterialForm, baseContainerMaterial(row)); dialogVisible.value = true } function openRule(row?: WmsTransportRule) { dialogKind.value = 'rule'; Object.assign(ruleForm, baseRule(row)); dialogVisible.value = true } +function openGenerateBins() { + generateBinsAreaId.value = areas.value[0]?.id ?? '' + generateBinsVisible.value = true +} + +async function submitGenerateBins() { + if (!generateBinsAreaId.value) { ElMessage.warning('请选择库区'); return } + saving.value = true + try { + const r = await wmsApi.generateBins(generateBinsAreaId.value, { ...generateBinsForm }) + ElMessage.success(`已生成 ${r.created} 个货位`) + generateBinsVisible.value = false + await loadAll() + } catch (e) { + ElMessage.error(e instanceof Error ? e.message : String(e)) + } finally { saving.value = false } +} async function loadTransportRules() { transportRules.value = await wmsApi.listTransportRules() } async function loadTransportTasks() { transportTasks.value = await wmsApi.listTransportTasks({ status: taskStatusFilter.value || undefined }) } @@ -388,10 +544,30 @@ async function submitDialog() { saving.value = true const kind = dialogKind.value try { - JSON.parse((kind === 'storage' ? storageForm : kind === 'material' ? materialForm : kind === 'rule' ? ruleForm : masterForm).extend || '{}') - if (kind === 'area') await wmsApi.saveArea(masterForm) - else if (kind === 'container') await wmsApi.saveContainer(masterForm) + const extendSrc = kind === 'storage' ? storageForm + : kind === 'material' ? materialForm + : kind === 'materialType' ? materialTypeForm + : kind === 'rule' ? ruleForm + : masterForm + JSON.parse(extendSrc.extend || '{}') + if (kind === 'area') { + await wmsApi.saveArea({ + ...masterForm, + warehouseId: undefined, + layoutMode: areaLayoutMode.value, + state: 'Default' + }) + } + else if (kind === 'container') { + await wmsApi.saveContainer({ + ...masterForm, + barcode: containerBarcode.value, + length: 0, width: 0, height: 0, + status: 'EmptyMaterial' + }) + } else if (kind === 'storage') await wmsApi.saveStorage(storageForm) + else if (kind === 'materialType') await wmsApi.saveMaterialType(materialTypeForm) else if (kind === 'material') await wmsApi.saveMaterial(materialForm) else if (kind === 'location') await wmsApi.saveContainerLocation(locationForm) else if (kind === 'rule') { @@ -400,7 +576,7 @@ async function submitDialog() { JSON.parse(ruleForm.taskOptionsJson || '{}') await wmsApi.saveTransportRule(ruleForm) } - else await wmsApi.saveContainerMaterial(containerMaterialForm) + else await wmsApi.bindMaterial(containerMaterialForm) ElMessage.success('已保存') dialogVisible.value = false await loadAll() @@ -419,9 +595,20 @@ async function confirmDelete(message: string) { async function removeArea(row: WarehouseArea) { await confirmDelete('删除该库区?'); await wmsApi.deleteArea(row.id, row.version); await loadAll() } async function removeStorage(row: Storage) { await confirmDelete('删除该库位?'); await wmsApi.deleteStorage(row.id, row.version); await loadAll() } async function removeContainer(row: Container) { await confirmDelete('删除该容器?'); await wmsApi.deleteContainer(row.id, row.version); await loadAll() } +async function removeMaterialType(row: MaterialType) { await confirmDelete('删除该物料类型?'); await wmsApi.deleteMaterialType(row.id, row.version); await loadAll() } async function removeMaterial(row: Material) { await confirmDelete('删除该物料?'); await wmsApi.deleteMaterial(row.id, row.version); await loadAll() } +async function archiveMaterial(row: Material) { + await confirmDelete(`归档物料 ${row.code}?归档后仍保留主数据,可后续 purge。`) + await wmsApi.archiveMaterial(row.id, row.version) + ElMessage.success('已归档') + await loadAll() +} async function removeLocation(row: ContainerLocation) { await confirmDelete('解绑该容器位置?'); await wmsApi.deleteContainerLocation(row.containerId); await loadAll() } -async function removeContainerMaterial(row: ContainerMaterial) { await confirmDelete('卸载该容器物料?'); await wmsApi.deleteContainerMaterial(row.id); await loadAll() } +async function removeContainerMaterial(row: ContainerMaterial, archive = false) { + await confirmDelete(archive ? '解绑并归档该物料?' : '解绑该容器物料?') + await wmsApi.deleteContainerMaterial(row.id, undefined, archive) + await loadAll() +} async function removeRule(row: WmsTransportRule) { await confirmDelete('删除该规则?'); await wmsApi.deleteTransportRule(row.id, row.version); await loadTransportRules() } async function reserveTask(row: WmsTransportTask) { await wmsApi.reserveTransportTask(row.id); ElMessage.success('已预占'); await loadTransportTasks() } async function completeTask(row: WmsTransportTask) { await wmsApi.completeTransportTask(row.id); ElMessage.success('已完成'); await loadAll() } @@ -439,6 +626,10 @@ async function batchDeleteContainers() { const r = await batchContainers.runBatch({ actionLabel: '批量删除', confirmMessage: '确认删除选中的 {n} 个容器?', run: (row) => wmsApi.deleteContainer(row.id, row.version) }) if (r.ok > 0) await loadAll() } +async function batchDeleteMaterialTypes() { + const r = await batchMaterialTypes.runBatch({ actionLabel: '批量删除', confirmMessage: '确认删除选中的 {n} 个物料类型?', run: (row) => wmsApi.deleteMaterialType(row.id, row.version) }) + if (r.ok > 0) await loadAll() +} async function batchDeleteMaterials() { const r = await batchMaterials.runBatch({ actionLabel: '批量删除', confirmMessage: '确认删除选中的 {n} 个物料?', run: (row) => wmsApi.deleteMaterial(row.id, row.version) }) if (r.ok > 0) await loadAll() @@ -500,13 +691,15 @@ function baseMaster(row?: WarehouseArea | Container): MasterDataPayload { function baseStorage(row?: Storage): StoragePayload { return { ...baseCommon(row), areaId: row?.areaId ?? areas.value[0]?.id ?? '', code: row?.code ?? '', name: row?.name ?? '', - storageType: row?.storageType ?? 'Storage', siteId: row?.siteId ?? '', capacity: row?.capacity ?? 0, - status: row?.status ?? 'Available', usage: row?.usage ?? '', priority: row?.priority ?? 0, zoneCode: row?.zoneCode ?? '', + storageType: row?.storageType ?? 'Storage', locationKind: row?.locationKind ?? 'Station', + columnNo: row?.columnNo ?? 0, levelNo: row?.levelNo ?? 1, depthNo: row?.depthNo ?? 1, + siteId: row?.siteId ?? '', siteCode: row?.siteCode ?? row?.siteId ?? '', barcode: row?.barcode ?? '', capacity: 1, + status: row?.status ?? 'Empty', usage: row?.usage ?? '', priority: row?.priority ?? 0, zoneCode: row?.zoneCode ?? '', allowInbound: row?.allowInbound ?? true, allowOutbound: row?.allowOutbound ?? true, enabled: row?.enabled ?? true } } function baseRule(row?: WmsTransportRule): TransportRulePayload { - const defaultSource = JSON.stringify({ schemaVersion: 1, storage: { requireOccupied: true, allowOutbound: true }, container: { excludeReserved: true }, material: { statusIn: ['Loaded'] }, ranking: [{ field: 'enteredAt', direction: 'asc' }] }, null, 2) + const defaultSource = JSON.stringify({ schemaVersion: 1, storage: { requireOccupied: true, allowOutbound: true }, container: { excludeReserved: true }, material: {}, ranking: [{ field: 'enteredAt', direction: 'asc' }] }, null, 2) const defaultTarget = JSON.stringify({ schemaVersion: 1, storage: { requireEmpty: true, allowInbound: true, requireSiteId: true }, ranking: [{ field: 'storagePriority', direction: 'desc' }] }, null, 2) const defaultOptions = JSON.stringify({ autoReserve: true, requireManualConfirm: false, allowSameStorage: false, reservationTtlSeconds: 600, dispatchMode: 'Manual', maxCandidateCount: 20 }, null, 2) return { @@ -517,14 +710,31 @@ function baseRule(row?: WmsTransportRule): TransportRulePayload { taskOptionsJson: row?.taskOptionsJson ?? defaultOptions } } +function baseMaterialType(row?: MaterialType): MaterialTypePayload { + return { + ...baseCommon(row), code: row?.code ?? '', name: row?.name ?? '', + spec: row?.spec ?? '', unit: row?.unit ?? 'pcs', category: row?.category ?? '', + barcodePrefix: row?.barcodePrefix ?? '', enabled: row?.enabled ?? true + } +} function baseMaterial(row?: Material): MaterialPayload { - return { ...baseCommon(row), code: row?.code ?? '', name: row?.name ?? '', spec: row?.spec ?? '', unit: row?.unit ?? 'pcs', category: row?.category ?? '', enabled: row?.enabled ?? true } + return { + ...baseCommon(row), code: row?.code ?? '', name: row?.name ?? '', typeCode: row?.typeCode ?? materialTypes.value[0]?.code ?? '', + barcode: row?.barcode ?? '', spec: row?.spec ?? '', unit: row?.unit ?? 'pcs', category: row?.category ?? '', + lifecycleStatus: row?.lifecycleStatus ?? 'Active', enabled: row?.enabled ?? true + } } function baseLocation(row?: ContainerLocation): ContainerLocationPayload { return { ...baseCommon(row), containerId: row?.containerId ?? containers.value[0]?.id ?? '', locationType: row?.locationType ?? 'Storage', locationId: row?.locationId ?? '', status: row?.status ?? 'Active', enteredAt: row?.enteredAt, source: 'Manual', reason: '' } } -function baseContainerMaterial(row?: ContainerMaterial): ContainerMaterialPayload { - return { ...baseCommon(row), containerId: row?.containerId ?? containers.value[0]?.id ?? '', materialId: row?.materialId ?? materials.value[0]?.id ?? '', quantity: row?.quantity ?? 1, batchNo: row?.batchNo ?? '', serialNo: row?.serialNo ?? '', status: row?.status ?? 'Loaded', loadedAt: row?.loadedAt, source: 'Manual', reason: '' } +function baseContainerMaterial(row?: ContainerMaterial): BindMaterialPayload { + return { + ...baseCommon(row), + containerId: row?.containerId ?? containers.value[0]?.id ?? '', + materialId: row?.materialId ?? materials.value[0]?.id ?? '', + source: 'Manual', + reason: '' + } } onMounted(loadAll) diff --git a/frontends/apps/simple-platform-vue/src/views/admin/warehouse/AreaFacadePanel.vue b/frontends/apps/simple-platform-vue/src/views/admin/warehouse/AreaFacadePanel.vue new file mode 100644 index 0000000..a7bdecc --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/admin/warehouse/AreaFacadePanel.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/admin/warehouse/WmsEntityDialog.vue b/frontends/apps/simple-platform-vue/src/views/admin/warehouse/WmsEntityDialog.vue index 2007be6..79aefbf 100644 --- a/frontends/apps/simple-platform-vue/src/views/admin/warehouse/WmsEntityDialog.vue +++ b/frontends/apps/simple-platform-vue/src/views/admin/warehouse/WmsEntityDialog.vue @@ -13,6 +13,7 @@ + @@ -21,7 +22,7 @@ - + @@ -29,7 +30,13 @@ + + {{ sitesLoadError }} 主数据来自 SimpleLite,保存值为站点 id - + - 展示用途;业务占用按 1 库位 1 容器 + + @@ -78,11 +110,9 @@