Files
Migu2.0/MiGu.Server/Wms/WmsService.cs
T
ArtoriasWu 15405ba114 新增 WMS 搬运规则与任务管理
支持搬运规则维护、候选预览、任务生成、预占、下发、取消和完成,并完善仓储管理前端交互。
2026-06-25 11:02:22 +08:00

534 lines
20 KiB
C#

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.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<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("容器位置状态无效");
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<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,
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";
}