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> Areas(string? q = null) => FilterByKeyword(_db.WarehouseAreas.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> 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> 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> ContainerMaterials(string? q = null) => FilterByKeyword(_db.ContainerMaterials.AsNoTracking().OrderBy(x => x.ContainerId), q).ToListAsync(); public async Task 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 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.Status = req.Status.TrimOr(StorageStatuses.Available); entity.Usage = req.Usage.TrimOr(""); entity.Priority = req.Priority; entity.ZoneCode = req.ZoneCode.TrimOr(""); entity.AllowInbound = req.AllowInbound; entity.AllowOutbound = req.AllowOutbound; entity.Enabled = req.Enabled; ApplyCommon(entity, req, actor); await _db.SaveChangesAsync(); return entity; } public async Task 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 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(Guid id, long? version, string actor) where T : EntityBase { var set = _db.Set(); 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 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("容器位置状态无效"); if (string.Equals(req.LocationType, ContainerLocationTypes.Storage, StringComparison.OrdinalIgnoreCase) && Guid.TryParse(req.LocationId, out var targetStorageId)) { var occupied = await _db.ContainerLocations.AsNoTracking() .AnyAsync(x => x.LocationType == ContainerLocationTypes.Storage && x.LocationId == req.LocationId && x.ContainerId != req.ContainerId); if (occupied) throw new InvalidOperationException("目标库位已被其他容器占用,库位与容器为 1 对 1"); } 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 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> 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> 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 FindEditable(DbSet 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(IQueryable query, System.Linq.Expressions.Expression> 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 FilterByKeyword(IQueryable query, string? q) { if (string.IsNullOrWhiteSpace(q)) return query; var s = q.Trim(); return typeof(T).Name switch { 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(ContainerLocation) => (IQueryable)((IQueryable)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, 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 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"; }