This commit is contained in:
zhaowei.huang
2026-06-23 13:50:25 +08:00
45 changed files with 3126 additions and 35 deletions
+143
View File
@@ -0,0 +1,143 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using MiGu.Server.Wms;
namespace MiGu.Server.Controllers;
[ApiController]
[Authorize]
[TypeFilter(typeof(WmsExceptionFilter))]
[Route("api/wms")]
public sealed class WmsController : ControllerBase
{
private readonly WmsService _service;
public WmsController(WmsService service)
{
_service = service;
}
[HttpGet("areas")]
public Task<List<WarehouseArea>> Areas([FromQuery] string? q) => _service.Areas(q);
[HttpPost("areas")]
public async Task<IActionResult> SaveArea([FromBody] MasterDataRequest req) => Ok(await _service.SaveArea(req, User.ActorName()));
[HttpPut("areas/{id:guid}")]
public async Task<IActionResult> UpdateArea(Guid id, [FromBody] MasterDataRequest req) =>
Ok(await _service.SaveArea(req with { Id = id }, User.ActorName()));
[HttpDelete("areas/{id:guid}")]
public async Task<IActionResult> DeleteArea(Guid id, [FromQuery] long? version)
{
await _service.DeleteEntity<WarehouseArea>(id, version, User.ActorName());
return NoContent();
}
[HttpGet("storages")]
public Task<List<Storage>> Storages([FromQuery] string? q) => _service.Storages(q);
[HttpPost("storages")]
public async Task<IActionResult> SaveStorage([FromBody] StorageRequest req) => Ok(await _service.SaveStorage(req, User.ActorName()));
[HttpPut("storages/{id:guid}")]
public async Task<IActionResult> UpdateStorage(Guid id, [FromBody] StorageRequest req) =>
Ok(await _service.SaveStorage(req with { Id = id }, User.ActorName()));
[HttpDelete("storages/{id:guid}")]
public async Task<IActionResult> DeleteStorage(Guid id, [FromQuery] long? version)
{
await _service.DeleteEntity<Storage>(id, version, User.ActorName());
return NoContent();
}
[HttpGet("containers")]
public Task<List<Container>> Containers([FromQuery] string? q) => _service.Containers(q);
[HttpPost("containers")]
public async Task<IActionResult> SaveContainer([FromBody] MasterDataRequest req) => Ok(await _service.SaveContainer(req, User.ActorName()));
[HttpPut("containers/{id:guid}")]
public async Task<IActionResult> UpdateContainer(Guid id, [FromBody] MasterDataRequest req) =>
Ok(await _service.SaveContainer(req with { Id = id }, User.ActorName()));
[HttpDelete("containers/{id:guid}")]
public async Task<IActionResult> DeleteContainer(Guid id, [FromQuery] long? version)
{
await _service.DeleteEntity<Container>(id, version, User.ActorName());
return NoContent();
}
[HttpGet("materials")]
public Task<List<Material>> Materials([FromQuery] string? q) => _service.Materials(q);
[HttpPost("materials")]
public async Task<IActionResult> SaveMaterial([FromBody] MaterialRequest req) => Ok(await _service.SaveMaterial(req, User.ActorName()));
[HttpPut("materials/{id:guid}")]
public async Task<IActionResult> UpdateMaterial(Guid id, [FromBody] MaterialRequest req) =>
Ok(await _service.SaveMaterial(req with { Id = id }, User.ActorName()));
[HttpDelete("materials/{id:guid}")]
public async Task<IActionResult> DeleteMaterial(Guid id, [FromQuery] long? version)
{
await _service.DeleteEntity<Material>(id, version, User.ActorName());
return NoContent();
}
[HttpGet("container-locations")]
public Task<List<ContainerLocation>> ContainerLocations([FromQuery] string? locationType, [FromQuery] string? q) =>
_service.ContainerLocations(locationType, q);
[HttpPost("container-locations")]
public async Task<IActionResult> SaveContainerLocation([FromBody] ContainerLocationRequest req) =>
Ok(await _service.BindOrTransferLocation(req, User.ActorName()));
[HttpPost("container-locations/transfer")]
public async Task<IActionResult> TransferContainerLocation([FromBody] ContainerLocationRequest req) =>
Ok(await _service.BindOrTransferLocation(req, User.ActorName()));
[HttpDelete("container-locations/{containerId:guid}")]
public async Task<IActionResult> UnbindContainerLocation(Guid containerId, [FromQuery] string? reason)
{
await _service.UnbindLocation(containerId, User.ActorName(), reason ?? "");
return NoContent();
}
[HttpGet("container-materials")]
public Task<List<ContainerMaterial>> ContainerMaterials([FromQuery] string? q) => _service.ContainerMaterials(q);
[HttpPost("container-materials")]
public async Task<IActionResult> SaveContainerMaterial([FromBody] ContainerMaterialRequest req) =>
Ok(await _service.SaveContainerMaterial(req, User.ActorName()));
[HttpPut("container-materials/{id:guid}")]
public async Task<IActionResult> UpdateContainerMaterial(Guid id, [FromBody] ContainerMaterialRequest req) =>
Ok(await _service.SaveContainerMaterial(req with { Id = id }, User.ActorName()));
[HttpDelete("container-materials/{id:guid}")]
public async Task<IActionResult> UnloadContainerMaterial(Guid id, [FromQuery] string? reason)
{
await _service.UnloadMaterial(id, User.ActorName(), reason ?? "");
return NoContent();
}
[HttpGet("container-location-history")]
public Task<List<ContainerLocationHistory>> ContainerLocationHistory([FromQuery] Guid? containerId) =>
_service.LocationHistory(containerId);
[HttpGet("container-material-history")]
public Task<List<ContainerMaterialHistory>> ContainerMaterialHistory([FromQuery] Guid? containerId, [FromQuery] Guid? materialId) =>
_service.MaterialHistory(containerId, materialId);
}
public sealed class WmsExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is not InvalidOperationException ex) return;
context.Result = new BadRequestObjectResult(new { message = ex.Message });
context.ExceptionHandled = true;
}
}
+5
View File
@@ -16,6 +16,11 @@
<PackageReference Include="Yarp.ReverseProxy" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.10" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+17
View File
@@ -0,0 +1,17 @@
namespace MiGu.Server.Persistence;
public abstract class EntityBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public string DeletedBy { get; set; } = "";
public long Version { get; set; }
public bool IsLock { get; set; }
public string CreatedBy { get; set; } = "";
public string UpdatedBy { get; set; } = "";
public string Remark { get; set; } = "";
public string Extend { get; set; } = "{}";
}
@@ -0,0 +1,124 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MiGu.Server.Wms;
namespace MiGu.Server.Persistence;
public sealed class PlatformDbContext : DbContext
{
public PlatformDbContext(DbContextOptions<PlatformDbContext> options) : base(options) { }
public DbSet<WarehouseArea> WarehouseAreas => Set<WarehouseArea>();
public DbSet<Storage> Storages => Set<Storage>();
public DbSet<Container> Containers => Set<Container>();
public DbSet<Material> Materials => Set<Material>();
public DbSet<ContainerLocation> ContainerLocations => Set<ContainerLocation>();
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var guid = new ValueConverter<Guid, string>(
v => v.ToString("D"),
v => Guid.Parse(v));
var nullableGuid = new ValueConverter<Guid?, string?>(
v => v.HasValue ? v.Value.ToString("D") : null,
v => string.IsNullOrWhiteSpace(v) ? null : Guid.Parse(v));
foreach (var entity in modelBuilder.Model.GetEntityTypes())
{
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(guid).HasMaxLength(36);
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid?)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableGuid).HasMaxLength(36);
}
ConfigureEntityBase<WarehouseArea>(modelBuilder, "wms_areas");
ConfigureEntityBase<Storage>(modelBuilder, "wms_storages");
ConfigureEntityBase<Container>(modelBuilder, "wms_containers");
ConfigureEntityBase<Material>(modelBuilder, "wms_materials");
ConfigureEntityBase<ContainerLocation>(modelBuilder, "wms_container_locations");
ConfigureEntityBase<ContainerMaterial>(modelBuilder, "wms_container_materials");
ConfigureHistory<ContainerLocationHistory>(modelBuilder, "wms_container_location_history");
ConfigureHistory<ContainerMaterialHistory>(modelBuilder, "wms_container_material_history");
modelBuilder.Entity<WarehouseArea>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Storage>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Storage>().HasIndex(x => x.AreaId);
modelBuilder.Entity<Container>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Material>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<ContainerLocation>().HasIndex(x => x.ContainerId).IsUnique();
modelBuilder.Entity<ContainerLocation>().HasIndex(x => new { x.LocationType, x.LocationId });
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => new { x.ContainerId, x.MaterialId, x.BatchNo, x.SerialNo }).IsUnique();
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
StampEntities();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
StampEntities();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void StampEntities()
{
var now = DateTimeOffset.UtcNow;
foreach (var e in ChangeTracker.Entries<EntityBase>())
{
if (e.State == EntityState.Added)
{
if (e.Entity.Id == Guid.Empty) e.Entity.Id = Guid.NewGuid();
e.Entity.CreatedAt = now;
e.Entity.UpdatedAt = now;
e.Entity.Version = Math.Max(1, e.Entity.Version);
if (string.IsNullOrWhiteSpace(e.Entity.Extend)) e.Entity.Extend = "{}";
}
else if (e.State == EntityState.Modified)
{
e.Entity.UpdatedAt = now;
e.Entity.Version += 1;
if (string.IsNullOrWhiteSpace(e.Entity.Extend)) e.Entity.Extend = "{}";
}
}
}
private static void ConfigureEntityBase<T>(ModelBuilder modelBuilder, string table) where T : EntityBase
{
var e = modelBuilder.Entity<T>();
e.ToTable(table);
e.HasKey(x => x.Id);
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.Property(x => x.UpdatedBy).HasMaxLength(128);
e.Property(x => x.DeletedBy).HasMaxLength(128);
e.Property(x => x.Remark).HasMaxLength(1000);
e.Property(x => x.Extend).HasColumnType("text");
e.HasQueryFilter(x => !x.IsDeleted);
}
private static void ConfigureHistory<T>(ModelBuilder modelBuilder, string table) where T : WarehouseHistoryBase
{
var e = modelBuilder.Entity<T>();
e.ToTable(table);
e.HasKey(x => x.Id);
e.Property(x => x.EventType).HasMaxLength(64);
e.Property(x => x.BeforeJson).HasColumnType("text");
e.Property(x => x.AfterJson).HasColumnType("text");
e.Property(x => x.Operator).HasMaxLength(128);
e.Property(x => x.Source).HasMaxLength(32);
e.Property(x => x.Reason).HasMaxLength(500);
e.Property(x => x.Remark).HasMaxLength(1000);
e.Property(x => x.Extend).HasColumnType("text");
e.HasIndex(x => x.ContainerId);
e.HasIndex(x => x.OperatedAt);
e.HasIndex(x => x.EventType);
}
}
@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Data.Sqlite;
using MiGu.Server.Wms;
namespace MiGu.Server.Persistence;
public static class PlatformPersistence
{
public static IServiceCollection AddPlatformPersistence(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<PlatformDbContext>((sp, options) =>
{
var env = sp.GetRequiredService<IWebHostEnvironment>();
var provider = configuration["Database:Provider"] ?? "sqlite";
var connection = ResolveConnectionString(configuration, env, provider);
switch (provider.Trim().ToLowerInvariant())
{
case "sqlite":
options.UseSqlite(connection);
break;
case "mysql":
options.UseMySql(connection, ServerVersion.AutoDetect(connection));
break;
case "postgres":
case "postgresql":
case "npgsql":
options.UseNpgsql(connection);
break;
case "sqlserver":
case "mssql":
options.UseSqlServer(connection);
break;
default:
throw new InvalidOperationException($"未知数据库 Provider: {provider}");
}
});
services.AddScoped<WmsReferenceValidator>();
services.AddScoped<WmsService>();
return services;
}
public static async Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
await db.Database.EnsureCreatedAsync();
}
private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider)
{
var key = provider.Trim().ToLowerInvariant() switch
{
"postgres" or "postgresql" or "npgsql" => "PostgreSQL",
"mssql" => "SqlServer",
_ => provider
};
var configured = configuration.GetConnectionString(key) ?? configuration.GetConnectionString("Platform");
if (!string.IsNullOrWhiteSpace(configured))
{
return IsSqlite(provider) ? NormalizeSqliteConnection(configured, env) : configured;
}
var dataDir = Path.Combine(env.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
return $"Data Source={Path.Combine(dataDir, "platform.db")}";
}
private static bool IsSqlite(string provider) =>
string.Equals(provider.Trim(), "sqlite", StringComparison.OrdinalIgnoreCase);
private static string NormalizeSqliteConnection(string connection, IWebHostEnvironment env)
{
var builder = new SqliteConnectionStringBuilder(connection);
if (string.IsNullOrWhiteSpace(builder.DataSource)) return connection;
if (builder.DataSource is ":memory:") return connection;
if (!Path.IsPathRooted(builder.DataSource))
{
builder.DataSource = Path.Combine(env.ContentRootPath, builder.DataSource);
}
var dir = Path.GetDirectoryName(builder.DataSource);
if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir);
return builder.ToString();
}
}
+3 -1
View File
@@ -6,7 +6,7 @@ using Microsoft.OpenApi.Models;
using MiGu.Server.Auth;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
using MiGu.Server.OpenApi;
using MiGu.Server.Persistence;
using Yarp.ReverseProxy.Transforms;
static string? FindSourceContentRoot(string startDir)
@@ -216,6 +216,7 @@ builder.Services.AddSingleton<ConfigStore>();
builder.Services.AddSingleton<OpsAuditStore>();
// OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。
builder.Services.AddHttpClient();
builder.Services.AddPlatformPersistence(builder.Configuration);
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DIAuthController 登录成功后按 LaunchMode 调 MaybeStart。
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
@@ -223,6 +224,7 @@ builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("
builder.Services.AddSingleton<SimpleLiteLauncher>();
var app = builder.Build();
await app.Services.EnsurePlatformDatabaseAsync();
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
_ = app.Services.GetRequiredService<JwtIssuer>();
+120
View File
@@ -0,0 +1,120 @@
using System.ComponentModel.DataAnnotations;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
public abstract class WarehouseHistoryBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? RelationId { get; set; }
public string EventType { get; set; } = "";
public string BeforeJson { get; set; } = "{}";
public string AfterJson { get; set; } = "{}";
public Guid? ContainerId { get; set; }
public string Operator { get; set; } = "";
public DateTimeOffset OperatedAt { get; set; }
public string Source { get; set; } = "Manual";
public string Reason { get; set; } = "";
public string Remark { get; set; } = "";
public string Extend { get; set; } = "{}";
}
public sealed class WarehouseArea : EntityBase
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string Type { get; set; } = "Storage";
public bool Enabled { get; set; } = true;
public int SortOrder { get; set; }
}
public sealed class Storage : EntityBase
{
public Guid AreaId { get; set; }
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string StorageType { get; set; } = "Storage";
[MaxLength(64)] public string SiteId { get; set; } = "";
public int Capacity { get; set; }
public bool Enabled { get; set; } = true;
}
public sealed class Container : EntityBase
{
[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";
public bool Enabled { get; set; } = true;
}
public sealed class Material : 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; } = "";
public bool Enabled { get; set; } = true;
}
public sealed class ContainerLocation : EntityBase
{
public Guid ContainerId { get; set; }
[MaxLength(32)] public string LocationType { get; set; } = ContainerLocationTypes.Storage;
[MaxLength(64)] public string LocationId { get; set; } = "";
[MaxLength(64)] public string LocationCode { get; set; } = "";
[MaxLength(128)] public string LocationName { get; set; } = "";
[MaxLength(32)] public string Status { get; set; } = ContainerLocationStatuses.Active;
public DateTimeOffset EnteredAt { get; set; }
}
public sealed class ContainerMaterial : EntityBase
{
public Guid ContainerId { get; set; }
public Guid MaterialId { get; set; }
public decimal Quantity { get; set; }
[MaxLength(64)] public string BatchNo { get; set; } = "";
[MaxLength(64)] public string SerialNo { get; set; } = "";
[MaxLength(32)] public string Status { get; set; } = ContainerMaterialStatuses.Loaded;
public DateTimeOffset LoadedAt { get; set; }
public DateTimeOffset? UnloadedAt { get; set; }
}
public sealed class ContainerLocationHistory : WarehouseHistoryBase
{
[MaxLength(32)] public string FromLocationType { get; set; } = "";
[MaxLength(64)] public string FromLocationId { get; set; } = "";
[MaxLength(32)] public string ToLocationType { get; set; } = "";
[MaxLength(64)] public string ToLocationId { get; set; } = "";
}
public sealed class ContainerMaterialHistory : WarehouseHistoryBase
{
public Guid? MaterialId { get; set; }
public decimal QuantityDelta { get; set; }
}
public static class ContainerLocationTypes
{
public const string Storage = "Storage";
public const string Car = "Car";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Storage, Car };
}
public static class ContainerLocationStatuses
{
public const string Active = "Active";
public const string Locked = "Locked";
public const string Exception = "Exception";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Active, Locked, Exception };
}
public static class ContainerMaterialStatuses
{
public const string Loaded = "Loaded";
public const string Unloaded = "Unloaded";
public const string Adjusted = "Adjusted";
public const string Frozen = "Frozen";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Loaded, Unloaded, Adjusted, Frozen };
}
+57
View File
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
public sealed class WmsReferenceValidator
{
private readonly PlatformDbContext _db;
public WmsReferenceValidator(PlatformDbContext db)
{
_db = db;
}
public async Task EnsureAreaAsync(Guid id)
{
if (!await _db.WarehouseAreas.AnyAsync(x => x.Id == id))
throw new InvalidOperationException("库区不存在");
}
public async Task EnsureStorageAsync(Guid id)
{
if (!await _db.Storages.AnyAsync(x => x.Id == id))
throw new InvalidOperationException("库位不存在");
}
public async Task EnsureContainerAsync(Guid id)
{
if (!await _db.Containers.AnyAsync(x => x.Id == id))
throw new InvalidOperationException("容器不存在");
}
public async Task EnsureMaterialAsync(Guid id)
{
if (!await _db.Materials.AnyAsync(x => x.Id == id))
throw new InvalidOperationException("物料不存在");
}
public async Task<(string Code, string Name)> ResolveLocationSnapshotAsync(string locationType, string locationId)
{
if (!ContainerLocationTypes.All.Contains(locationType))
throw new InvalidOperationException("位置类型无效");
if (string.IsNullOrWhiteSpace(locationId))
throw new InvalidOperationException("位置 ID 不能为空");
if (string.Equals(locationType, ContainerLocationTypes.Storage, StringComparison.OrdinalIgnoreCase))
{
if (!Guid.TryParse(locationId, out var id)) throw new InvalidOperationException("库位 ID 格式无效");
var s = await _db.Storages.FirstOrDefaultAsync(x => x.Id == id);
if (s == null) throw new InvalidOperationException("库位不存在");
return (s.Code, s.Name);
}
// 车辆来自现有调度/投影系统,首期不建库内外键,保留原始 ID 并作为快照展示。
return (locationId, locationId);
}
}
+510
View File
@@ -0,0 +1,510 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
public sealed class WmsService
{
private readonly PlatformDbContext _db;
private readonly WmsReferenceValidator _refs;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public WmsService(PlatformDbContext db, WmsReferenceValidator refs)
{
_db = db;
_refs = refs;
}
public Task<List<WarehouseArea>> Areas(string? q = null) =>
FilterByKeyword(_db.WarehouseAreas.AsNoTracking().OrderBy(x => x.SortOrder).ThenBy(x => x.Code), q).ToListAsync();
public Task<List<Storage>> Storages(string? q = null) =>
FilterByKeyword(_db.Storages.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync();
public Task<List<Container>> Containers(string? q = null) =>
FilterByKeyword(_db.Containers.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync();
public Task<List<Material>> Materials(string? q = null) =>
FilterByKeyword(_db.Materials.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync();
public Task<List<ContainerLocation>> ContainerLocations(string? locationType = null, string? q = null)
{
var query = _db.ContainerLocations.AsNoTracking().OrderBy(x => x.ContainerId).AsQueryable();
if (!string.IsNullOrWhiteSpace(locationType)) query = query.Where(x => x.LocationType == locationType);
return FilterByKeyword(query, q).ToListAsync();
}
public Task<List<ContainerMaterial>> ContainerMaterials(string? q = null) =>
FilterByKeyword(_db.ContainerMaterials.AsNoTracking().OrderBy(x => x.ContainerId), q).ToListAsync();
public async Task<WarehouseArea> SaveArea(MasterDataRequest req, string actor)
{
WarehouseArea entity;
if (req.Id.HasValue)
{
entity = await FindEditable(_db.WarehouseAreas, req.Id.Value, req.Version);
}
else
{
entity = new WarehouseArea();
StampCreate(entity, actor);
_db.WarehouseAreas.Add(entity);
}
await EnsureUnique(_db.WarehouseAreas, x => x.Code == req.Code && x.Id != entity.Id, "库区编码已存在");
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.Type = req.Type.TrimOr("Storage");
entity.Enabled = req.Enabled;
entity.SortOrder = req.SortOrder;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
return entity;
}
public async Task<Storage> SaveStorage(StorageRequest req, string actor)
{
await _refs.EnsureAreaAsync(req.AreaId);
Storage entity;
if (req.Id.HasValue)
{
entity = await FindEditable(_db.Storages, req.Id.Value, req.Version);
}
else
{
entity = new Storage();
StampCreate(entity, actor);
_db.Storages.Add(entity);
}
await EnsureUnique(_db.Storages, x => x.Code == req.Code && x.Id != entity.Id, "库位编码已存在");
entity.AreaId = req.AreaId;
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.StorageType = req.StorageType.TrimOr("Storage");
entity.SiteId = req.SiteId.TrimOr("");
entity.Capacity = req.Capacity;
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
return entity;
}
public async Task<Container> SaveContainer(MasterDataRequest req, string actor)
{
Container entity;
if (req.Id.HasValue)
{
entity = await FindEditable(_db.Containers, req.Id.Value, req.Version);
}
else
{
entity = new Container();
StampCreate(entity, actor);
_db.Containers.Add(entity);
}
await EnsureUnique(_db.Containers, x => x.Code == req.Code && x.Id != entity.Id, "容器编码已存在");
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.ContainerType = req.Type.TrimOr("Box");
entity.Status = req.Status.TrimOr("Idle");
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
return entity;
}
public async Task<Material> SaveMaterial(MaterialRequest req, string actor)
{
Material entity;
if (req.Id.HasValue)
{
entity = await FindEditable(_db.Materials, req.Id.Value, req.Version);
}
else
{
entity = new Material();
StampCreate(entity, actor);
_db.Materials.Add(entity);
}
await EnsureUnique(_db.Materials, 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.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
return entity;
}
public async Task DeleteEntity<T>(Guid id, long? version, string actor) where T : EntityBase
{
var set = _db.Set<T>();
var entity = await FindEditable(set, id, version);
entity.IsDeleted = true;
entity.DeletedAt = DateTimeOffset.UtcNow;
entity.DeletedBy = actor;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
}
public async Task<ContainerLocation> BindOrTransferLocation(ContainerLocationRequest req, string actor)
{
await _refs.EnsureContainerAsync(req.ContainerId);
var (code, name) = await _refs.ResolveLocationSnapshotAsync(req.LocationType, req.LocationId);
if (!ContainerLocationStatuses.All.Contains(req.Status))
throw new InvalidOperationException("容器位置状态无效");
var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == req.ContainerId);
var before = current == null ? null : Snapshot(current);
var now = DateTimeOffset.UtcNow;
if (current == null)
{
current = new ContainerLocation { ContainerId = req.ContainerId, EnteredAt = now };
StampCreate(current, actor);
_db.ContainerLocations.Add(current);
}
else
{
EnsureVersion(current, req.Version);
EnsureUnlocked(current);
}
current.LocationType = req.LocationType;
current.LocationId = req.LocationId.Trim();
current.LocationCode = code;
current.LocationName = name;
current.Status = req.Status;
current.EnteredAt = req.EnteredAt ?? now;
ApplyCommon(current, req, actor);
_db.ContainerLocationHistories.Add(new ContainerLocationHistory
{
RelationId = current.Id,
ContainerId = current.ContainerId,
EventType = before == null ? "Bind" : "Transfer",
FromLocationType = before?.LocationType ?? "",
FromLocationId = before?.LocationId ?? "",
ToLocationType = current.LocationType,
ToLocationId = current.LocationId,
BeforeJson = before == null ? "{}" : JsonSerializer.Serialize(before, _json),
AfterJson = JsonSerializer.Serialize(Snapshot(current), _json),
Operator = actor,
OperatedAt = now,
Source = req.Source.TrimOr("Manual"),
Reason = req.Reason.TrimOr(""),
Remark = req.Remark.TrimOr(""),
Extend = NormalizeExtend(req.Extend)
});
await _db.SaveChangesAsync();
return current;
}
public async Task UnbindLocation(Guid containerId, string actor, string reason = "")
{
var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == containerId)
?? throw new InvalidOperationException("容器当前位置不存在");
EnsureUnlocked(current);
var before = Snapshot(current);
_db.ContainerLocationHistories.Add(new ContainerLocationHistory
{
RelationId = current.Id,
ContainerId = current.ContainerId,
EventType = "Unbind",
FromLocationType = current.LocationType,
FromLocationId = current.LocationId,
BeforeJson = JsonSerializer.Serialize(before, _json),
AfterJson = "{}",
Operator = actor,
OperatedAt = DateTimeOffset.UtcNow,
Source = "Manual",
Reason = reason
});
_db.ContainerLocations.Remove(current);
await _db.SaveChangesAsync();
}
public async Task<ContainerMaterial> SaveContainerMaterial(ContainerMaterialRequest 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 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)
{
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;
ApplyCommon(current, req, actor);
_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),
AfterJson = JsonSerializer.Serialize(Snapshot(current), _json),
Operator = actor,
OperatedAt = DateTimeOffset.UtcNow,
Source = req.Source.TrimOr("Manual"),
Reason = req.Reason.TrimOr(""),
Remark = req.Remark.TrimOr(""),
Extend = NormalizeExtend(req.Extend)
});
await _db.SaveChangesAsync();
return current;
}
public async Task UnloadMaterial(Guid id, string actor, string reason = "")
{
var current = await _db.ContainerMaterials.FirstOrDefaultAsync(x => x.Id == id)
?? throw new InvalidOperationException("容器物料不存在");
EnsureUnlocked(current);
var before = Snapshot(current);
_db.ContainerMaterialHistories.Add(new ContainerMaterialHistory
{
RelationId = current.Id,
ContainerId = current.ContainerId,
MaterialId = current.MaterialId,
EventType = "Unload",
QuantityDelta = -current.Quantity,
BeforeJson = JsonSerializer.Serialize(before, _json),
AfterJson = "{}",
Operator = actor,
OperatedAt = DateTimeOffset.UtcNow,
Source = "Manual",
Reason = reason
});
_db.ContainerMaterials.Remove(current);
await _db.SaveChangesAsync();
}
public async Task<List<ContainerLocationHistory>> LocationHistory(Guid? containerId = null)
{
var rows = await (containerId.HasValue
? _db.ContainerLocationHistories.AsNoTracking().Where(x => x.ContainerId == containerId.Value)
: _db.ContainerLocationHistories.AsNoTracking())
.ToListAsync();
return rows.OrderByDescending(x => x.OperatedAt).Take(500).ToList();
}
public async Task<List<ContainerMaterialHistory>> MaterialHistory(Guid? containerId = null, Guid? materialId = null)
{
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<T> FindEditable<T>(DbSet<T> set, Guid id, long? version) where T : EntityBase
{
var entity = await set.FirstOrDefaultAsync(x => x.Id == id) ?? throw new InvalidOperationException("数据不存在");
EnsureVersion(entity, version);
EnsureUnlocked(entity);
return entity;
}
private static void EnsureVersion(EntityBase entity, long? version)
{
if (version.HasValue && entity.Version != version.Value)
throw new InvalidOperationException("数据已被其他用户修改,请刷新后重试");
}
private static void EnsureUnlocked(EntityBase entity)
{
if (entity.IsLock) throw new InvalidOperationException("数据已锁定,不能修改");
}
private static void StampCreate(EntityBase entity, string actor)
{
entity.CreatedBy = actor;
entity.UpdatedBy = actor;
}
private void ApplyCommon(EntityBase entity, CommonRequest req, string actor)
{
entity.IsLock = req.IsLock;
entity.Remark = req.Remark.TrimOr("");
entity.Extend = NormalizeExtend(req.Extend);
entity.UpdatedBy = actor;
}
private static async Task EnsureUnique<T>(IQueryable<T> query, System.Linq.Expressions.Expression<Func<T, bool>> predicate, string message)
{
if (await query.AnyAsync(predicate)) throw new InvalidOperationException(message);
}
private string NormalizeExtend(string? extend)
{
if (string.IsNullOrWhiteSpace(extend)) return "{}";
if (extend.Length > 4000) throw new InvalidOperationException("扩展字段过长");
using var doc = JsonDocument.Parse(extend);
if (doc.RootElement.ValueKind != JsonValueKind.Object) throw new InvalidOperationException("扩展字段必须是 JSON object");
return extend;
}
private static IQueryable<T> FilterByKeyword<T>(IQueryable<T> query, string? q)
{
if (string.IsNullOrWhiteSpace(q)) return query;
var s = q.Trim();
return typeof(T).Name switch
{
nameof(WarehouseArea) => (IQueryable<T>)((IQueryable<WarehouseArea>)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s)),
nameof(Storage) => (IQueryable<T>)((IQueryable<Storage>)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.SiteId.Contains(s)),
nameof(Container) => (IQueryable<T>)((IQueryable<Container>)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s)),
nameof(Material) => (IQueryable<T>)((IQueryable<Material>)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.Spec.Contains(s)),
nameof(ContainerLocation) => (IQueryable<T>)((IQueryable<ContainerLocation>)query).Where(x => x.LocationCode.Contains(s) || x.LocationName.Contains(s)),
nameof(ContainerMaterial) => query,
_ => query
};
}
private static ContainerLocationSnapshot Snapshot(ContainerLocation x) => new(
x.Id, x.ContainerId, x.LocationType, x.LocationId, x.LocationCode, x.LocationName, x.Status, x.EnteredAt, x.Version);
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);
}
public sealed record ContainerLocationSnapshot(
Guid Id,
Guid ContainerId,
string LocationType,
string LocationId,
string LocationCode,
string LocationName,
string Status,
DateTimeOffset EnteredAt,
long Version);
public sealed record ContainerMaterialSnapshot(
Guid Id,
Guid ContainerId,
Guid MaterialId,
decimal Quantity,
string BatchNo,
string SerialNo,
string Status,
DateTimeOffset LoadedAt,
DateTimeOffset? UnloadedAt,
long Version);
public abstract record CommonRequest(
Guid? Id,
long? Version,
bool IsLock,
string Remark,
string Extend);
public sealed record MasterDataRequest(
Guid? Id,
long? Version,
string Code,
string Name,
string Type,
string Status,
bool Enabled,
int SortOrder,
bool IsLock,
string Remark,
string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record StorageRequest(
Guid? Id,
long? Version,
Guid AreaId,
string Code,
string Name,
string StorageType,
string SiteId,
int Capacity,
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);
public sealed record ContainerLocationRequest(
Guid? Id,
long? Version,
Guid ContainerId,
string LocationType,
string LocationId,
string Status,
DateTimeOffset? EnteredAt,
string Source,
string Reason,
bool IsLock,
string Remark,
string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record ContainerMaterialRequest(
Guid? Id,
long? Version,
Guid ContainerId,
Guid MaterialId,
decimal Quantity,
string BatchNo,
string SerialNo,
string Status,
DateTimeOffset? LoadedAt,
DateTimeOffset? UnloadedAt,
string Source,
string Reason,
bool IsLock,
string Remark,
string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public static class WmsStringExtensions
{
public static string TrimOr(this string? value, string fallback) =>
string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
public static string ActorName(this ClaimsPrincipal user) =>
user.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.UniqueName)?.Value
?? user.Identity?.Name
?? "system";
}
+1
View File
@@ -0,0 +1 @@
Gl4ghnjUoi2/dEh1Uv4DE5qjuqefIqSpVwg5/ZbIlvdq1g93+vFuwX30N+NRSdzU
+61
View File
@@ -0,0 +1,61 @@
{
"section": "auth",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4979022+00:00",
"payload": {
"roles": [
{
"id": "role-admin",
"name": "\u7BA1\u7406\u5458",
"scope": "Platform",
"permissions": [
"*"
],
"widgetGrants": []
},
{
"id": "role-ops",
"name": "\u8FD0\u8425",
"scope": "RCSMonitor",
"permissions": [
"ops.car.pause",
"ops.car.resume",
"ops.car.gohome",
"ops.task.pause",
"ops.task.cancel",
"ops.task.reassign",
"ops.task.boostPriority",
"monitor.note.write"
],
"widgetGrants": [
{
"widgetId": "MapEditor",
"visibility": "readonly"
},
{
"widgetId": "CadToolbar",
"visibility": "hidden"
}
]
}
],
"users": [
{
"id": "u-admin",
"username": "admin",
"roles": [
"role-admin"
],
"enabled": true
},
{
"id": "u-ops",
"username": "ops",
"roles": [
"role-ops"
],
"enabled": true
}
]
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"section": "charge",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4849334+00:00",
"payload": {
"allowMidTaskCharge": false,
"idleChargeAfterSec": 300,
"priority": [
{
"id": "CP1",
"condition": "soc\u003C0.2",
"weight": 100
},
{
"id": "CP2",
"condition": "idle\u003E5min",
"weight": 30
}
]
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"section": "deployment",
"version": 7,
"updatedAt": "2026-06-09T01:43:46.9419888+00:00",
"payload": {
"configured": true,
"platformType": "standard",
"modules": [
"wms"
],
"navigationKinds": [
"qrcode",
"laser"
],
"scenarios": [
"tpl-p2p"
],
"updatedBy": "admin"
}
}
+64
View File
@@ -0,0 +1,64 @@
{
"section": "device",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.5034914+00:00",
"payload": {
"drivers": [
{
"id": "drv-elev",
"deviceType": "\u7535\u68AF",
"driverName": "OpcUaElevatorDriver",
"version": "1.2.0"
},
{
"id": "drv-chrg",
"deviceType": "\u5145\u7535\u6869",
"driverName": "ModbusChargerDriver",
"version": "1.0.5"
},
{
"id": "drv-cam",
"deviceType": "\u6444\u50CF\u5934",
"driverName": "OnvifCameraDriver",
"version": "2.1.0"
}
],
"devices": [
{
"id": "dev-elev-1",
"name": "#1 \u7535\u68AF",
"deviceType": "\u7535\u68AF",
"protocol": "opc-ua",
"address": "opc.tcp://10.0.2.20:4840",
"driverId": "drv-elev",
"enabled": true
},
{
"id": "dev-chrg-1",
"name": "\u5145\u7535\u6869-A1",
"deviceType": "\u5145\u7535\u6869",
"protocol": "modbus-tcp",
"address": "10.0.2.30:502",
"driverId": "drv-chrg",
"enabled": true
}
],
"healthPolicy": {
"heartbeatSec": 5,
"offlineSec": 30
},
"alarmPolicy": {
"enabled": true,
"rules": [
{
"level": "warn",
"condition": "offline\u003E30s"
},
{
"level": "error",
"condition": "driverException"
}
]
}
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"section": "fleet",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.5109848+00:00",
"payload": {
"groups": [
{
"id": "G-A",
"name": "A \u533A\u8F66\u961F",
"floor": "F1",
"region": "A",
"carIds": [
"C01",
"C02",
"C03"
]
},
{
"id": "G-B",
"name": "B \u533A\u8F66\u961F",
"floor": "F1",
"region": "B",
"carIds": [
"C04",
"C05"
]
}
],
"ota": {
"enabled": true,
"batchSize": 2,
"rollbackOnFail": true
},
"batchOps": {
"confirmationRequired": true,
"maxBatch": 10
},
"networkDiag": {
"rttThresholdMs": 80,
"packetLossThreshold": 0.02
}
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"section": "integrations",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4625787+00:00",
"payload": {
"mes": [
{
"id": "mes-1",
"name": "MES \u4E3B\u7EBF",
"url": "http://mes.lan/api",
"enabled": true
}
],
"wms": [
{
"id": "wms-1",
"name": "WMS \u4ED3\u50A8",
"url": "http://wms.lan/api",
"enabled": true
}
],
"rcs": [],
"ptl": [
{
"id": "ptl-1",
"name": "PTL \u62E3\u9009",
"url": "http://ptl.lan/api",
"enabled": true
}
]
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"section": "location",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.5220964+00:00",
"payload": {
"locations": [
{
"id": "L01",
"code": "A-01",
"name": "A \u533A\u8D27\u67B6 1",
"siteId": "S001",
"capacity": 20,
"occupied": 12
},
{
"id": "L02",
"code": "A-02",
"name": "A \u533A\u8D27\u67B6 2",
"siteId": "S002",
"capacity": 20,
"occupied": 7
},
{
"id": "L03",
"code": "B-01",
"name": "B \u533A\u7F13\u5B58",
"siteId": "S003",
"capacity": 30,
"occupied": 25
}
],
"inventoryRules": [
{
"id": "IR1",
"itemType": "PalletA",
"minQty": 5,
"maxQty": 30
}
]
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"section": "ops",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.5273514+00:00",
"payload": {
"playback": {
"retentionDays": 30,
"samplingHz": 5
},
"logRetention": {
"hotDays": 7,
"coldDays": 180
},
"version": {
"keepReleases": 5
},
"monitor": {
"car": {
"propertyKeys": [],
"statusKeys": [],
"actionKeys": []
},
"site": {
"propertyKeys": [],
"statusKeys": [],
"actionKeys": []
},
"track": {
"propertyKeys": [],
"statusKeys": [],
"actionKeys": []
},
"carActionByType": {}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"section": "routing",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4685445+00:00",
"payload": {
"algorithm": "astar",
"weights": {
"distance": 1,
"congestion": 0.5,
"turnPenalty": 0.2
},
"avoidance": [
{
"id": "AV1",
"zoneId": "Z-NORTH",
"rule": "no-entry-while-loading"
}
],
"zoneSpeedLimits": [
{
"zoneId": "Z-NARROW",
"maxSpeedMps": 0.5
}
]
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"section": "scenario",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.5171692+00:00",
"payload": {
"templates": [
{
"id": "tpl-sps",
"name": "SPS \u7269\u6599\u914D\u9001",
"category": "SPS",
"version": "1.0.0",
"baselineJson": "{}"
},
{
"id": "tpl-pack",
"name": "\u7535\u6C60 Pack \u81EA\u52A8\u5316\u4EA7\u7EBF",
"category": "BatteryPack",
"version": "1.0.0",
"baselineJson": "{}"
},
{
"id": "tpl-loop",
"name": "\u73AF\u7EBF\u8FD0\u884C",
"category": "Loop",
"version": "1.0.0",
"baselineJson": "{}"
},
{
"id": "tpl-p2p",
"name": "\u70B9\u5BF9\u70B9\u67D4\u6027\u642C\u8FD0",
"category": "P2P",
"version": "1.0.0",
"baselineJson": "{}"
}
],
"dslPolicy": {
"enabled": true,
"schemaVersion": "1"
},
"lowCode": {
"enabled": false,
"editor": "json"
},
"versionPolicy": {
"keepVersions": 10,
"allowRollback": true
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"section": "system",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4470202+00:00",
"payload": {
"dispatchLoopHz": 50,
"log": {
"level": "info",
"rollDays": 7,
"maxSizeMB": 256
},
"security": {
"jwtExpireMin": 1440,
"enableSwagger": false,
"corsWhitelist": [
"http://localhost:5173"
]
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"section": "task",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4893595+00:00",
"payload": {
"mode": "leastLoad",
"loadBalance": true,
"maxQueuePerCar": 3
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"section": "traffic",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4927371+00:00",
"payload": {
"intersections": [
{
"id": "IX1",
"siteIds": [
"S006",
"S007"
],
"mode": "mutex"
}
],
"mutex": [
{
"id": "MZ1",
"zoneIds": [
"Z-CROSS"
]
}
],
"yields": [
{
"id": "YD1",
"from": "A \u533A",
"to": "B \u533A",
"condition": "priority\u003Cpeer"
}
]
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"section": "vehicle",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4789283+00:00",
"payload": {
"lowBatteryThreshold": 0.3,
"criticalBatteryThreshold": 0.15,
"faultReport": {
"enabled": true,
"emailTo": [
"ops@example.com"
]
},
"autoRepair": {
"enabled": false,
"cooldownSec": 600
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"section": "widget",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.5362337+00:00",
"payload": {
"items": [
{
"id": "widget-call-button",
"name": "\u547C\u53EB\u6309\u94AE",
"schemaJson": "{\u0022fields\u0022:[{\u0022name\u0022:\u0022siteId\u0022}]}",
"layoutJson": "{\u0022x\u0022:0,\u0022y\u0022:0,\u0022w\u0022:2,\u0022h\u0022:1}",
"bindToScopes": [
"RCSMonitor"
]
}
]
}
}
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
{
"version": 1,
"roles": [
{
"id": "role-admin",
"name": "\u8D85\u7EA7\u7BA1\u7406\u5458",
"description": "\u62E5\u6709\u5168\u90E8\u9875\u9762\u4E0E\u64CD\u4F5C\u6743\u9650\u7684\u5185\u7F6E\u89D2\u8272",
"scope": "*",
"pages": [
"*"
],
"ops": [
"*"
],
"widgetGrants": [],
"system": true
},
{
"id": "role-ops",
"name": "\u8FD0\u8425\u4EBA\u5458",
"description": "\u8FD0\u8425\u76D1\u63A7\u7AEF\u9ED8\u8BA4\u89D2\u8272\uFF1A\u53EF\u6267\u884C\u8FD0\u7EF4\u64CD\u4F5C\u3001\u67E5\u770B\u76D1\u63A7",
"scope": "RCSMonitor",
"pages": [
"monitor-dashboard",
"monitor-map",
"monitor-ops",
"monitor-notes"
],
"ops": [
"ops.car.pause",
"ops.car.resume",
"ops.car.gohome",
"ops.car.resetSession",
"ops.car.manualCharge",
"ops.task.pause",
"ops.task.cancel",
"ops.task.reassign",
"ops.task.boostPriority",
"monitor.note.write"
],
"widgetGrants": [
{
"widgetId": "MapEditor",
"visibility": "readonly"
},
{
"widgetId": "CadToolbar",
"visibility": "hidden"
},
{
"widgetId": "CarPanel",
"visibility": "readonly"
},
{
"widgetId": "MissionEditor",
"visibility": "readonly"
},
{
"widgetId": "OpsActionPanel",
"visibility": "interactive"
},
{
"widgetId": "ConfigCenter",
"visibility": "hidden"
}
],
"system": true
}
],
"users": [
{
"id": "u-admin",
"username": "admin",
"displayName": "\u7CFB\u7EDF\u7BA1\u7406\u5458",
"enabled": true,
"roleIds": [
"role-admin"
],
"salt": "pQlotjtkEe0S5MTxecJd4A==",
"passwordHash": "qs6A3I/Y3OwV\u002Bs95orIWvzcUz8bF4ZY9l1jq4SQd1cI="
},
{
"id": "u-ops",
"username": "ops",
"displayName": "\u8FD0\u8425\u4EBA\u5458",
"enabled": true,
"roleIds": [
"role-ops"
],
"salt": "G6c5\u002BnLEjf3fEor1LgNjyg==",
"passwordHash": "Zb1QiDzHctduFbZGKniTEy4OLJIvIOeoyLfc\u002Bo0y\u002BVg="
}
]
}