后端实现基于EF Core的库区/库位/容器/物料/关系/历史等模型、服务与RESTful接口,支持多数据库Provider。前端新增类型、API与聚合页面,支持主数据及容器位置/物料关系的增删改查、绑定/解绑、装料/卸料、历史追溯。完善权限、菜单与文档,平台具备完整WMS能力。
511 lines
19 KiB
C#
511 lines
19 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.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";
|
|
}
|