| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- 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;
- /// <summary>
- /// S0 库位主数据(LocationMaster 语义)+ 货架明细(LocationShelfMaster)主从保存。
- /// 多租户隔离:所有读写显式限定当前请求租户(AdoS0TenantScope.TryResolveRequired),不依赖全局 AOP。
- /// </summary>
- [ApiController]
- [Route("api/s0/warehouse/locations")]
- [NonUnify]
- public class AdoS0LocationsController : ControllerBase
- {
- private const int MaxShelves = 5000;
- private readonly SqlSugarRepository<AdoS0LocationMaster> _rep;
- private readonly SqlSugarRepository<AdoS0LocationShelfMaster> _shelfRep;
- private readonly AdoS0ReferenceChecker _refChecker;
- public AdoS0LocationsController(
- SqlSugarRepository<AdoS0LocationMaster> rep,
- SqlSugarRepository<AdoS0LocationShelfMaster> shelfRep,
- AdoS0ReferenceChecker refChecker)
- {
- _rep = rep;
- _shelfRep = shelfRep;
- _refChecker = refChecker;
- }
- [HttpGet]
- public async Task<IActionResult> 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 });
- }
- /// <summary>
- /// 库位详情(含货架明细,供编辑回显)。按 id + 当前租户定位;他租户记录统一返回 NotFound(不泄露存在性)。
- /// </summary>
- [HttpGet("{id:long}")]
- public async Task<IActionResult> 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<IActionResult> 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);
- }
- /// <summary>
- /// 新增库位(含货架明细,主从同事务保存)。tenant_id 由服务端按当前租户赋值。
- /// </summary>
- [HttpPost]
- public async Task<IActionResult> 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);
- }
- }
- /// <summary>
- /// 编辑库位(含货架明细 FULL Replace,主从同事务保存)。按 id + 当前租户定位;库位编码不可修改;忽略前端传入的 tenant。
- /// </summary>
- [HttpPut("{id:long}")]
- public async Task<IActionResult> 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<IActionResult> 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 = "删除成功" });
- }
- // ==================== 私有:货架明细主从辅助 ====================
- /// <summary>
- /// 按关联口径(当前租户 + domain_code + location)拉取库位下货架明细。
- /// </summary>
- private async Task<List<AdoS0LocationShelfInputDto>> 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();
- }
- /// <summary>
- /// 服务端货架明细校验 + 规范化(Trim、长度、请求内去重、数量上限)。前端校验只是体验,服务端为准。
- /// </summary>
- private static (IActionResult? Error, List<AdoS0LocationShelfInputDto> Items) ValidateShelves(List<AdoS0LocationShelfInputDto>? shelves)
- {
- var items = shelves ?? new List<AdoS0LocationShelfInputDto>();
- if (items.Count > MaxShelves)
- return (AdoS0ApiErrors.InvalidRequest($"货架明细数量 {items.Count} 超过上限 {MaxShelves},请缩小货架序号范围/层数/列数后重试"), new());
- var normalized = new List<AdoS0LocationShelfInputDto>(items.Count);
- // 去重按数据库不区分大小写口径(MySQL 默认 ci 排序规则),避免与唯一索引冲突
- var seen = new HashSet<string>(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);
- }
- /// <summary>
- /// 由库位主表 + 当前租户统一赋值货架作用域字段(tenant_id 显式赋值,不依赖 AOP)。
- /// </summary>
- private static List<AdoS0LocationShelfMaster> BuildShelfEntities(
- AdoS0LocationMaster master, List<AdoS0LocationShelfInputDto> 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();
- }
- /// <summary>
- /// 落库前断言:主表与全部货架的 TenantId 均等于当前租户且非空非 0(W-01 NULL 防线)。
- /// </summary>
- private static void AssertTenant(long tenantId, AdoS0LocationMaster master, List<AdoS0LocationShelfMaster> shelves)
- {
- if (tenantId <= 0 || master.TenantId != tenantId || shelves.Any(s => s.TenantId != tenantId))
- throw new InvalidOperationException("租户归属断言失败:主表/货架 TenantId 必须等于当前租户且非空。");
- }
- /// <summary>
- /// 写入异常映射:唯一键冲突 → 清晰业务错误;其余 → 500(事务已回滚)。
- /// </summary>
- 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("库位与货架明细保存失败,已整体回滚");
- }
- }
|