using Admin.NET.Plugin.AiDOP.Dto.S0.Warehouse;
using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
using Admin.NET.Plugin.AiDOP.Infrastructure;
namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
///
/// S0 库位主数据(LocationMaster 语义)+ 货架明细(LocationShelfMaster)主从保存。
/// 多租户隔离:所有读写显式限定当前请求租户(AdoS0TenantScope.TryResolveRequired),不依赖全局 AOP。
///
[ApiController]
[Route("api/s0/warehouse/locations")]
[NonUnify]
public class AdoS0LocationsController : ControllerBase
{
private const int MaxShelves = 5000;
private readonly SqlSugarRepository _rep;
private readonly SqlSugarRepository _shelfRep;
private readonly AdoS0ReferenceChecker _refChecker;
public AdoS0LocationsController(
SqlSugarRepository rep,
SqlSugarRepository shelfRep,
AdoS0ReferenceChecker refChecker)
{
_rep = rep;
_shelfRep = shelfRep;
_refChecker = refChecker;
}
[HttpGet]
public async Task GetPagedAsync([FromQuery] AdoS0LocationQueryDto q)
{
if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
(q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
var query = _rep.AsQueryable()
.Where(x => x.TenantId == tenantId)
.WhereIF(q.CompanyRefId.HasValue, x => x.CompanyRefId == q.CompanyRefId!.Value)
.WhereIF(q.FactoryRefId.HasValue, x => x.FactoryRefId == q.FactoryRefId!.Value)
.WhereIF(!string.IsNullOrWhiteSpace(q.DomainCode), x => x.DomainCode == q.DomainCode)
.WhereIF(!string.IsNullOrWhiteSpace(q.Typed), x => x.Typed == q.Typed)
.WhereIF(q.IsActive.HasValue, x => x.IsActive == q.IsActive!.Value)
.WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
x => x.Location.Contains(q.Keyword!) || (x.Descr != null && x.Descr.Contains(q.Keyword!)));
var total = await query.CountAsync();
var list = await query
.OrderBy(x => x.Typed == "Supp" ? 2 : 0)
.OrderBy(x => x.Location)
.Skip((q.Page - 1) * q.PageSize)
.Take(q.PageSize)
.ToListAsync();
return Ok(new { total, page = q.Page, pageSize = q.PageSize, list });
}
///
/// 库位详情(含货架明细,供编辑回显)。按 id + 当前租户定位;他租户记录统一返回 NotFound(不泄露存在性)。
///
[HttpGet("{id:long}")]
public async Task GetAsync(long id)
{
if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
if (item == null) return NotFound();
var shelves = await LoadShelvesAsync(item, tenantId);
var detail = new AdoS0LocationDetailDto
{
Id = item.Id,
CompanyRefId = item.CompanyRefId,
FactoryRefId = item.FactoryRefId,
DomainCode = item.DomainCode,
Location = item.Location,
Descr = item.Descr,
Storer = item.Storer,
Typed = item.Typed,
PhysicalAddress = item.PhysicalAddress,
IsActive = item.IsActive,
CreateUser = item.CreateUser,
CreateTime = item.CreateTime,
UpdateUser = item.UpdateUser,
UpdateTime = item.UpdateTime,
Shelves = shelves
};
return Ok(detail);
}
[HttpGet("options")]
public async Task GetOptionsAsync(
[FromQuery] long? companyRefId,
[FromQuery] long? factoryRefId,
[FromQuery] string? domainCode,
[FromQuery] string? keyword,
[FromQuery] bool? isActive,
[FromQuery] int? limit)
{
if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
var enabledFilter = isActive ?? true;
var take = Math.Clamp(limit ?? 200, 1, 500);
var list = await _rep.AsQueryable()
.Where(x => x.TenantId == tenantId)
.WhereIF(companyRefId.HasValue, x => x.CompanyRefId == companyRefId!.Value)
.WhereIF(factoryRefId.HasValue, x => x.FactoryRefId == factoryRefId!.Value)
.WhereIF(!string.IsNullOrWhiteSpace(domainCode), x => x.DomainCode == domainCode)
.WhereIF(!string.IsNullOrWhiteSpace(keyword),
x => x.Location.Contains(keyword!) || (x.Descr != null && x.Descr.Contains(keyword!)))
.Where(x => x.IsActive == enabledFilter)
.OrderBy(x => x.Location)
.Take(take)
.Select(x => new S0LocationOptionRow
{
Value = x.Location,
Label = x.Descr == null || x.Descr == "" ? x.Location : x.Location + " / " + x.Descr,
Code = x.Location,
Name = x.Descr,
DomainCode = x.DomainCode,
IsActive = x.IsActive
})
.ToListAsync();
return Ok(list);
}
///
/// 新增库位(含货架明细,主从同事务保存)。tenant_id 由服务端按当前租户赋值。
///
[HttpPost]
public async Task CreateAsync([FromBody] AdoS0LocationUpsertDto dto)
{
if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
var (shelfError, shelfItems) = ValidateShelves(dto.Shelves);
if (shelfError != null) return shelfError;
if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "库位编码已存在");
var now = DateTime.Now;
var entity = new AdoS0LocationMaster
{
TenantId = tenantId,
CompanyRefId = dto.CompanyRefId,
FactoryRefId = dto.FactoryRefId,
DomainCode = dto.DomainCode ?? string.Empty,
Location = dto.Location,
Descr = dto.Descr,
Storer = dto.Storer,
Typed = dto.Typed,
PhysicalAddress = dto.PhysicalAddress,
IsActive = dto.IsActive,
CreateUser = dto.CreateUser,
CreateTime = now
};
var db = _rep.Context;
try
{
await db.Ado.BeginTranAsync();
var saved = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
if (shelfItems.Count > 0)
{
var shelfEntities = BuildShelfEntities(saved, shelfItems, tenantId, dto.CreateUser, now);
AssertTenant(tenantId, saved, shelfEntities);
await _shelfRep.AsInsertable(shelfEntities).ExecuteCommandAsync();
}
await db.Ado.CommitTranAsync();
return Ok(saved);
}
catch (Exception ex)
{
await db.Ado.RollbackTranAsync();
return MapWriteException(ex);
}
}
///
/// 编辑库位(含货架明细 FULL Replace,主从同事务保存)。按 id + 当前租户定位;库位编码不可修改;忽略前端传入的 tenant。
///
[HttpPut("{id:long}")]
public async Task UpdateAsync(long id, [FromBody] AdoS0LocationUpsertDto dto)
{
if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
var (shelfError, shelfItems) = ValidateShelves(dto.Shelves);
if (shelfError != null) return shelfError;
var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
if (entity == null) return NotFound();
// 库位编码不可修改:以库存原值为准,前端传值须一致
if (!string.Equals(entity.Location, dto.Location?.Trim(), StringComparison.Ordinal))
return AdoS0ApiErrors.InvalidRequest("库位编码不可修改");
if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "库位编码已存在");
var now = DateTime.Now;
// entity.TenantId 保持不变(== tenantId);不接受前端修改 tenant
entity.CompanyRefId = dto.CompanyRefId;
entity.FactoryRefId = dto.FactoryRefId;
entity.DomainCode = dto.DomainCode ?? string.Empty;
// entity.Location 保持不变(不可修改)
entity.Descr = dto.Descr;
entity.Storer = dto.Storer;
entity.Typed = dto.Typed;
entity.PhysicalAddress = dto.PhysicalAddress;
entity.IsActive = dto.IsActive;
entity.UpdateUser = dto.UpdateUser;
entity.UpdateTime = now;
var db = _rep.Context;
try
{
await db.Ado.BeginTranAsync();
await _rep.AsUpdateable(entity).ExecuteCommandAsync();
// FULL Replace:显式限定 tenant + domain + location 删本库位全部货架,再整体重插(禁止仅按 domain+location)
await _shelfRep.AsDeleteable()
.Where(x => x.TenantId == tenantId && x.DomainCode == entity.DomainCode && x.Location == entity.Location)
.ExecuteCommandAsync();
if (shelfItems.Count > 0)
{
var shelfEntities = BuildShelfEntities(entity, shelfItems, tenantId, dto.UpdateUser, now);
AssertTenant(tenantId, entity, shelfEntities);
await _shelfRep.AsInsertable(shelfEntities).ExecuteCommandAsync();
}
await db.Ado.CommitTranAsync();
return Ok(entity);
}
catch (Exception ex)
{
await db.Ado.RollbackTranAsync();
return MapWriteException(ex);
}
}
[HttpDelete("{id:long}")]
public async Task DeleteAsync(long id)
{
if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
if (item == null) return NotFound();
// 引用检查限定 当前租户 + domain + location:不因他租户同编码货架而阻止/误删
var refInfo = await _refChecker.LocationReferencesAsync(tenantId, item.DomainCode, item.Location);
if (refInfo is { } r)
return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
$"存在 {r.Count} 条 {r.Table} 引用该库位,无法删除");
await _rep.DeleteAsync(item);
return Ok(new { message = "删除成功" });
}
// ==================== 私有:货架明细主从辅助 ====================
///
/// 按关联口径(当前租户 + domain_code + location)拉取库位下货架明细。
///
private async Task> LoadShelvesAsync(AdoS0LocationMaster master, long tenantId)
{
return await _shelfRep.AsQueryable()
.Where(x => x.TenantId == tenantId && x.DomainCode == master.DomainCode && x.Location == master.Location)
.OrderBy(x => x.InvShelf)
.Select(x => new AdoS0LocationShelfInputDto
{
Id = x.Id,
InvShelf = x.InvShelf,
Descr = x.Descr,
Area = x.Area
})
.ToListAsync();
}
///
/// 服务端货架明细校验 + 规范化(Trim、长度、请求内去重、数量上限)。前端校验只是体验,服务端为准。
///
private static (IActionResult? Error, List Items) ValidateShelves(List? shelves)
{
var items = shelves ?? new List();
if (items.Count > MaxShelves)
return (AdoS0ApiErrors.InvalidRequest($"货架明细数量 {items.Count} 超过上限 {MaxShelves},请缩小货架序号范围/层数/列数后重试"), new());
var normalized = new List(items.Count);
// 去重按数据库不区分大小写口径(MySQL 默认 ci 排序规则),避免与唯一索引冲突
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var s in items)
{
var code = (s.InvShelf ?? string.Empty).Trim();
if (code.Length == 0)
return (AdoS0ApiErrors.InvalidRequest("存在货架编码为空的明细行,请填写货架编码或删除该行"), new());
if (code.Length > 100)
return (AdoS0ApiErrors.InvalidRequest($"货架编码 '{code}' 超过 100 字符上限"), new());
var descr = s.Descr?.Trim();
if (descr is { Length: > 255 })
return (AdoS0ApiErrors.InvalidRequest($"货架 '{code}' 的描述超过 255 字符上限"), new());
var area = s.Area?.Trim();
if (area is { Length: > 100 })
return (AdoS0ApiErrors.InvalidRequest($"货架 '{code}' 的区域超过 100 字符上限"), new());
if (!seen.Add(code))
return (AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateDetailItem, $"货架编码重复:{code}"), new());
normalized.Add(new AdoS0LocationShelfInputDto
{
InvShelf = code,
Descr = string.IsNullOrEmpty(descr) ? null : descr,
Area = string.IsNullOrEmpty(area) ? null : area
});
}
return (null, normalized);
}
///
/// 由库位主表 + 当前租户统一赋值货架作用域字段(tenant_id 显式赋值,不依赖 AOP)。
///
private static List BuildShelfEntities(
AdoS0LocationMaster master, List items, long tenantId, string? actingUser, DateTime now)
{
return items.Select(s => new AdoS0LocationShelfMaster
{
TenantId = tenantId,
CompanyRefId = master.CompanyRefId,
FactoryRefId = master.FactoryRefId,
DomainCode = master.DomainCode,
Location = master.Location,
InvShelf = s.InvShelf,
Descr = s.Descr,
Area = s.Area,
CreateUser = actingUser,
CreateTime = now
}).ToList();
}
///
/// 落库前断言:主表与全部货架的 TenantId 均等于当前租户且非空非 0(W-01 NULL 防线)。
///
private static void AssertTenant(long tenantId, AdoS0LocationMaster master, List shelves)
{
if (tenantId <= 0 || master.TenantId != tenantId || shelves.Any(s => s.TenantId != tenantId))
throw new InvalidOperationException("租户归属断言失败:主表/货架 TenantId 必须等于当前租户且非空。");
}
///
/// 写入异常映射:唯一键冲突 → 清晰业务错误;其余 → 500(事务已回滚)。
///
private static IActionResult MapWriteException(Exception ex)
{
var msg = ex.Message + " " + (ex.InnerException?.Message ?? string.Empty);
if (msg.Contains("Duplicate entry", StringComparison.OrdinalIgnoreCase) || msg.Contains("1062"))
return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateDetailItem, "货架编码在同一库位内重复(唯一约束冲突),保存已回滚");
return AdoS0ApiErrors.InternalServerError("库位与货架明细保存失败,已整体回滚");
}
}