AdoS0LocationsController.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. using Admin.NET.Plugin.AiDOP.Dto.S0.Warehouse;
  2. using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
  5. /// <summary>
  6. /// S0 库位主数据(LocationMaster 语义)+ 货架明细(LocationShelfMaster)主从保存。
  7. /// 多租户隔离:所有读写显式限定当前请求租户(AdoS0TenantScope.TryResolveRequired),不依赖全局 AOP。
  8. /// </summary>
  9. [ApiController]
  10. [Route("api/s0/warehouse/locations")]
  11. [NonUnify]
  12. public class AdoS0LocationsController : ControllerBase
  13. {
  14. private const int MaxShelves = 5000;
  15. private readonly SqlSugarRepository<AdoS0LocationMaster> _rep;
  16. private readonly SqlSugarRepository<AdoS0LocationShelfMaster> _shelfRep;
  17. private readonly AdoS0ReferenceChecker _refChecker;
  18. public AdoS0LocationsController(
  19. SqlSugarRepository<AdoS0LocationMaster> rep,
  20. SqlSugarRepository<AdoS0LocationShelfMaster> shelfRep,
  21. AdoS0ReferenceChecker refChecker)
  22. {
  23. _rep = rep;
  24. _shelfRep = shelfRep;
  25. _refChecker = refChecker;
  26. }
  27. [HttpGet]
  28. public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0LocationQueryDto q)
  29. {
  30. if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
  31. (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
  32. var query = _rep.AsQueryable()
  33. .Where(x => x.TenantId == tenantId)
  34. .WhereIF(q.CompanyRefId.HasValue, x => x.CompanyRefId == q.CompanyRefId!.Value)
  35. .WhereIF(q.FactoryRefId.HasValue, x => x.FactoryRefId == q.FactoryRefId!.Value)
  36. .WhereIF(!string.IsNullOrWhiteSpace(q.DomainCode), x => x.DomainCode == q.DomainCode)
  37. .WhereIF(!string.IsNullOrWhiteSpace(q.Typed), x => x.Typed == q.Typed)
  38. .WhereIF(q.IsActive.HasValue, x => x.IsActive == q.IsActive!.Value)
  39. .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
  40. x => x.Location.Contains(q.Keyword!) || (x.Descr != null && x.Descr.Contains(q.Keyword!)));
  41. var total = await query.CountAsync();
  42. var list = await query
  43. .OrderBy(x => x.Typed == "Supp" ? 2 : 0)
  44. .OrderBy(x => x.Location)
  45. .Skip((q.Page - 1) * q.PageSize)
  46. .Take(q.PageSize)
  47. .ToListAsync();
  48. return Ok(new { total, page = q.Page, pageSize = q.PageSize, list });
  49. }
  50. /// <summary>
  51. /// 库位详情(含货架明细,供编辑回显)。按 id + 当前租户定位;他租户记录统一返回 NotFound(不泄露存在性)。
  52. /// </summary>
  53. [HttpGet("{id:long}")]
  54. public async Task<IActionResult> GetAsync(long id)
  55. {
  56. if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
  57. var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
  58. if (item == null) return NotFound();
  59. var shelves = await LoadShelvesAsync(item, tenantId);
  60. var detail = new AdoS0LocationDetailDto
  61. {
  62. Id = item.Id,
  63. CompanyRefId = item.CompanyRefId,
  64. FactoryRefId = item.FactoryRefId,
  65. DomainCode = item.DomainCode,
  66. Location = item.Location,
  67. Descr = item.Descr,
  68. Storer = item.Storer,
  69. Typed = item.Typed,
  70. PhysicalAddress = item.PhysicalAddress,
  71. IsActive = item.IsActive,
  72. CreateUser = item.CreateUser,
  73. CreateTime = item.CreateTime,
  74. UpdateUser = item.UpdateUser,
  75. UpdateTime = item.UpdateTime,
  76. Shelves = shelves
  77. };
  78. return Ok(detail);
  79. }
  80. [HttpGet("options")]
  81. public async Task<IActionResult> GetOptionsAsync(
  82. [FromQuery] long? companyRefId,
  83. [FromQuery] long? factoryRefId,
  84. [FromQuery] string? domainCode,
  85. [FromQuery] string? keyword,
  86. [FromQuery] bool? isActive,
  87. [FromQuery] int? limit)
  88. {
  89. if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
  90. var enabledFilter = isActive ?? true;
  91. var take = Math.Clamp(limit ?? 200, 1, 500);
  92. var list = await _rep.AsQueryable()
  93. .Where(x => x.TenantId == tenantId)
  94. .WhereIF(companyRefId.HasValue, x => x.CompanyRefId == companyRefId!.Value)
  95. .WhereIF(factoryRefId.HasValue, x => x.FactoryRefId == factoryRefId!.Value)
  96. .WhereIF(!string.IsNullOrWhiteSpace(domainCode), x => x.DomainCode == domainCode)
  97. .WhereIF(!string.IsNullOrWhiteSpace(keyword),
  98. x => x.Location.Contains(keyword!) || (x.Descr != null && x.Descr.Contains(keyword!)))
  99. .Where(x => x.IsActive == enabledFilter)
  100. .OrderBy(x => x.Location)
  101. .Take(take)
  102. .Select(x => new S0LocationOptionRow
  103. {
  104. Value = x.Location,
  105. Label = x.Descr == null || x.Descr == "" ? x.Location : x.Location + " / " + x.Descr,
  106. Code = x.Location,
  107. Name = x.Descr,
  108. DomainCode = x.DomainCode,
  109. IsActive = x.IsActive
  110. })
  111. .ToListAsync();
  112. return Ok(list);
  113. }
  114. /// <summary>
  115. /// 新增库位(含货架明细,主从同事务保存)。tenant_id 由服务端按当前租户赋值。
  116. /// </summary>
  117. [HttpPost]
  118. public async Task<IActionResult> CreateAsync([FromBody] AdoS0LocationUpsertDto dto)
  119. {
  120. if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
  121. var (shelfError, shelfItems) = ValidateShelves(dto.Shelves);
  122. if (shelfError != null) return shelfError;
  123. if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
  124. return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "库位编码已存在");
  125. var now = DateTime.Now;
  126. var entity = new AdoS0LocationMaster
  127. {
  128. TenantId = tenantId,
  129. CompanyRefId = dto.CompanyRefId,
  130. FactoryRefId = dto.FactoryRefId,
  131. DomainCode = dto.DomainCode ?? string.Empty,
  132. Location = dto.Location,
  133. Descr = dto.Descr,
  134. Storer = dto.Storer,
  135. Typed = dto.Typed,
  136. PhysicalAddress = dto.PhysicalAddress,
  137. IsActive = dto.IsActive,
  138. CreateUser = dto.CreateUser,
  139. CreateTime = now
  140. };
  141. var db = _rep.Context;
  142. try
  143. {
  144. await db.Ado.BeginTranAsync();
  145. var saved = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  146. if (shelfItems.Count > 0)
  147. {
  148. var shelfEntities = BuildShelfEntities(saved, shelfItems, tenantId, dto.CreateUser, now);
  149. AssertTenant(tenantId, saved, shelfEntities);
  150. await _shelfRep.AsInsertable(shelfEntities).ExecuteCommandAsync();
  151. }
  152. await db.Ado.CommitTranAsync();
  153. return Ok(saved);
  154. }
  155. catch (Exception ex)
  156. {
  157. await db.Ado.RollbackTranAsync();
  158. return MapWriteException(ex);
  159. }
  160. }
  161. /// <summary>
  162. /// 编辑库位(含货架明细 FULL Replace,主从同事务保存)。按 id + 当前租户定位;库位编码不可修改;忽略前端传入的 tenant。
  163. /// </summary>
  164. [HttpPut("{id:long}")]
  165. public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0LocationUpsertDto dto)
  166. {
  167. if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
  168. var (shelfError, shelfItems) = ValidateShelves(dto.Shelves);
  169. if (shelfError != null) return shelfError;
  170. var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
  171. if (entity == null) return NotFound();
  172. // 库位编码不可修改:以库存原值为准,前端传值须一致
  173. if (!string.Equals(entity.Location, dto.Location?.Trim(), StringComparison.Ordinal))
  174. return AdoS0ApiErrors.InvalidRequest("库位编码不可修改");
  175. if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
  176. return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "库位编码已存在");
  177. var now = DateTime.Now;
  178. // entity.TenantId 保持不变(== tenantId);不接受前端修改 tenant
  179. entity.CompanyRefId = dto.CompanyRefId;
  180. entity.FactoryRefId = dto.FactoryRefId;
  181. entity.DomainCode = dto.DomainCode ?? string.Empty;
  182. // entity.Location 保持不变(不可修改)
  183. entity.Descr = dto.Descr;
  184. entity.Storer = dto.Storer;
  185. entity.Typed = dto.Typed;
  186. entity.PhysicalAddress = dto.PhysicalAddress;
  187. entity.IsActive = dto.IsActive;
  188. entity.UpdateUser = dto.UpdateUser;
  189. entity.UpdateTime = now;
  190. var db = _rep.Context;
  191. try
  192. {
  193. await db.Ado.BeginTranAsync();
  194. await _rep.AsUpdateable(entity).ExecuteCommandAsync();
  195. // FULL Replace:显式限定 tenant + domain + location 删本库位全部货架,再整体重插(禁止仅按 domain+location)
  196. await _shelfRep.AsDeleteable()
  197. .Where(x => x.TenantId == tenantId && x.DomainCode == entity.DomainCode && x.Location == entity.Location)
  198. .ExecuteCommandAsync();
  199. if (shelfItems.Count > 0)
  200. {
  201. var shelfEntities = BuildShelfEntities(entity, shelfItems, tenantId, dto.UpdateUser, now);
  202. AssertTenant(tenantId, entity, shelfEntities);
  203. await _shelfRep.AsInsertable(shelfEntities).ExecuteCommandAsync();
  204. }
  205. await db.Ado.CommitTranAsync();
  206. return Ok(entity);
  207. }
  208. catch (Exception ex)
  209. {
  210. await db.Ado.RollbackTranAsync();
  211. return MapWriteException(ex);
  212. }
  213. }
  214. [HttpDelete("{id:long}")]
  215. public async Task<IActionResult> DeleteAsync(long id)
  216. {
  217. if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
  218. var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
  219. if (item == null) return NotFound();
  220. // 引用检查限定 当前租户 + domain + location:不因他租户同编码货架而阻止/误删
  221. var refInfo = await _refChecker.LocationReferencesAsync(tenantId, item.DomainCode, item.Location);
  222. if (refInfo is { } r)
  223. return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
  224. $"存在 {r.Count} 条 {r.Table} 引用该库位,无法删除");
  225. await _rep.DeleteAsync(item);
  226. return Ok(new { message = "删除成功" });
  227. }
  228. // ==================== 私有:货架明细主从辅助 ====================
  229. /// <summary>
  230. /// 按关联口径(当前租户 + domain_code + location)拉取库位下货架明细。
  231. /// </summary>
  232. private async Task<List<AdoS0LocationShelfInputDto>> LoadShelvesAsync(AdoS0LocationMaster master, long tenantId)
  233. {
  234. return await _shelfRep.AsQueryable()
  235. .Where(x => x.TenantId == tenantId && x.DomainCode == master.DomainCode && x.Location == master.Location)
  236. .OrderBy(x => x.InvShelf)
  237. .Select(x => new AdoS0LocationShelfInputDto
  238. {
  239. Id = x.Id,
  240. InvShelf = x.InvShelf,
  241. Descr = x.Descr,
  242. Area = x.Area
  243. })
  244. .ToListAsync();
  245. }
  246. /// <summary>
  247. /// 服务端货架明细校验 + 规范化(Trim、长度、请求内去重、数量上限)。前端校验只是体验,服务端为准。
  248. /// </summary>
  249. private static (IActionResult? Error, List<AdoS0LocationShelfInputDto> Items) ValidateShelves(List<AdoS0LocationShelfInputDto>? shelves)
  250. {
  251. var items = shelves ?? new List<AdoS0LocationShelfInputDto>();
  252. if (items.Count > MaxShelves)
  253. return (AdoS0ApiErrors.InvalidRequest($"货架明细数量 {items.Count} 超过上限 {MaxShelves},请缩小货架序号范围/层数/列数后重试"), new());
  254. var normalized = new List<AdoS0LocationShelfInputDto>(items.Count);
  255. // 去重按数据库不区分大小写口径(MySQL 默认 ci 排序规则),避免与唯一索引冲突
  256. var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  257. foreach (var s in items)
  258. {
  259. var code = (s.InvShelf ?? string.Empty).Trim();
  260. if (code.Length == 0)
  261. return (AdoS0ApiErrors.InvalidRequest("存在货架编码为空的明细行,请填写货架编码或删除该行"), new());
  262. if (code.Length > 100)
  263. return (AdoS0ApiErrors.InvalidRequest($"货架编码 '{code}' 超过 100 字符上限"), new());
  264. var descr = s.Descr?.Trim();
  265. if (descr is { Length: > 255 })
  266. return (AdoS0ApiErrors.InvalidRequest($"货架 '{code}' 的描述超过 255 字符上限"), new());
  267. var area = s.Area?.Trim();
  268. if (area is { Length: > 100 })
  269. return (AdoS0ApiErrors.InvalidRequest($"货架 '{code}' 的区域超过 100 字符上限"), new());
  270. if (!seen.Add(code))
  271. return (AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateDetailItem, $"货架编码重复:{code}"), new());
  272. normalized.Add(new AdoS0LocationShelfInputDto
  273. {
  274. InvShelf = code,
  275. Descr = string.IsNullOrEmpty(descr) ? null : descr,
  276. Area = string.IsNullOrEmpty(area) ? null : area
  277. });
  278. }
  279. return (null, normalized);
  280. }
  281. /// <summary>
  282. /// 由库位主表 + 当前租户统一赋值货架作用域字段(tenant_id 显式赋值,不依赖 AOP)。
  283. /// </summary>
  284. private static List<AdoS0LocationShelfMaster> BuildShelfEntities(
  285. AdoS0LocationMaster master, List<AdoS0LocationShelfInputDto> items, long tenantId, string? actingUser, DateTime now)
  286. {
  287. return items.Select(s => new AdoS0LocationShelfMaster
  288. {
  289. TenantId = tenantId,
  290. CompanyRefId = master.CompanyRefId,
  291. FactoryRefId = master.FactoryRefId,
  292. DomainCode = master.DomainCode,
  293. Location = master.Location,
  294. InvShelf = s.InvShelf,
  295. Descr = s.Descr,
  296. Area = s.Area,
  297. CreateUser = actingUser,
  298. CreateTime = now
  299. }).ToList();
  300. }
  301. /// <summary>
  302. /// 落库前断言:主表与全部货架的 TenantId 均等于当前租户且非空非 0(W-01 NULL 防线)。
  303. /// </summary>
  304. private static void AssertTenant(long tenantId, AdoS0LocationMaster master, List<AdoS0LocationShelfMaster> shelves)
  305. {
  306. if (tenantId <= 0 || master.TenantId != tenantId || shelves.Any(s => s.TenantId != tenantId))
  307. throw new InvalidOperationException("租户归属断言失败:主表/货架 TenantId 必须等于当前租户且非空。");
  308. }
  309. /// <summary>
  310. /// 写入异常映射:唯一键冲突 → 清晰业务错误;其余 → 500(事务已回滚)。
  311. /// </summary>
  312. private static IActionResult MapWriteException(Exception ex)
  313. {
  314. var msg = ex.Message + " " + (ex.InnerException?.Message ?? string.Empty);
  315. if (msg.Contains("Duplicate entry", StringComparison.OrdinalIgnoreCase) || msg.Contains("1062"))
  316. return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateDetailItem, "货架编码在同一库位内重复(唯一约束冲突),保存已回滚");
  317. return AdoS0ApiErrors.InternalServerError("库位与货架明细保存失败,已整体回滚");
  318. }
  319. }