AdoS0LocationsController.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  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. // 域编码不可修改:与库位编码同口径。必须在 entity.DomainCode 被赋新值之前判断,
  176. // 否则 FULL Replace 会按新 DomainCode 删除货架,导致旧 DomainCode 下货架 orphan。
  177. if (!string.Equals(entity.DomainCode, (dto.DomainCode ?? string.Empty).Trim(), StringComparison.Ordinal))
  178. return AdoS0ApiErrors.InvalidRequest("域编码不可修改");
  179. if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
  180. return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "库位编码已存在");
  181. var now = DateTime.Now;
  182. // entity.TenantId 保持不变(== tenantId);不接受前端修改 tenant
  183. entity.CompanyRefId = dto.CompanyRefId;
  184. entity.FactoryRefId = dto.FactoryRefId;
  185. entity.DomainCode = dto.DomainCode ?? string.Empty;
  186. // entity.Location 保持不变(不可修改)
  187. entity.Descr = dto.Descr;
  188. entity.Storer = dto.Storer;
  189. entity.Typed = dto.Typed;
  190. entity.PhysicalAddress = dto.PhysicalAddress;
  191. entity.IsActive = dto.IsActive;
  192. entity.UpdateUser = dto.UpdateUser;
  193. entity.UpdateTime = now;
  194. var db = _rep.Context;
  195. try
  196. {
  197. await db.Ado.BeginTranAsync();
  198. await _rep.AsUpdateable(entity).ExecuteCommandAsync();
  199. // FULL Replace:显式限定 tenant + domain + location 删本库位全部货架,再整体重插(禁止仅按 domain+location)
  200. await _shelfRep.AsDeleteable()
  201. .Where(x => x.TenantId == tenantId && x.DomainCode == entity.DomainCode && x.Location == entity.Location)
  202. .ExecuteCommandAsync();
  203. if (shelfItems.Count > 0)
  204. {
  205. var shelfEntities = BuildShelfEntities(entity, shelfItems, tenantId, dto.UpdateUser, now);
  206. AssertTenant(tenantId, entity, shelfEntities);
  207. await _shelfRep.AsInsertable(shelfEntities).ExecuteCommandAsync();
  208. }
  209. await db.Ado.CommitTranAsync();
  210. return Ok(entity);
  211. }
  212. catch (Exception ex)
  213. {
  214. await db.Ado.RollbackTranAsync();
  215. return MapWriteException(ex);
  216. }
  217. }
  218. [HttpDelete("{id:long}")]
  219. public async Task<IActionResult> DeleteAsync(long id)
  220. {
  221. if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
  222. var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
  223. if (item == null) return NotFound();
  224. // 引用检查限定 当前租户 + domain + location:不因他租户同编码货架而阻止/误删
  225. var refInfo = await _refChecker.LocationReferencesAsync(tenantId, item.DomainCode, item.Location);
  226. if (refInfo is { } r)
  227. return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
  228. $"存在 {r.Count} 条 {r.Table} 引用该库位,无法删除");
  229. await _rep.DeleteAsync(item);
  230. return Ok(new { message = "删除成功" });
  231. }
  232. // ==================== 私有:货架明细主从辅助 ====================
  233. /// <summary>
  234. /// 按关联口径(当前租户 + domain_code + location)拉取库位下货架明细。
  235. /// </summary>
  236. private async Task<List<AdoS0LocationShelfInputDto>> LoadShelvesAsync(AdoS0LocationMaster master, long tenantId)
  237. {
  238. return await _shelfRep.AsQueryable()
  239. .Where(x => x.TenantId == tenantId && x.DomainCode == master.DomainCode && x.Location == master.Location)
  240. .OrderBy(x => x.InvShelf)
  241. .Select(x => new AdoS0LocationShelfInputDto
  242. {
  243. Id = x.Id,
  244. InvShelf = x.InvShelf,
  245. Descr = x.Descr,
  246. Area = x.Area
  247. })
  248. .ToListAsync();
  249. }
  250. /// <summary>
  251. /// 服务端货架明细校验 + 规范化(Trim、长度、请求内去重、数量上限)。前端校验只是体验,服务端为准。
  252. /// </summary>
  253. private static (IActionResult? Error, List<AdoS0LocationShelfInputDto> Items) ValidateShelves(List<AdoS0LocationShelfInputDto>? shelves)
  254. {
  255. var items = shelves ?? new List<AdoS0LocationShelfInputDto>();
  256. if (items.Count > MaxShelves)
  257. return (AdoS0ApiErrors.InvalidRequest($"货架明细数量 {items.Count} 超过上限 {MaxShelves},请缩小货架序号范围/层数/列数后重试"), new());
  258. var normalized = new List<AdoS0LocationShelfInputDto>(items.Count);
  259. // 去重按数据库不区分大小写口径(MySQL 默认 ci 排序规则),避免与唯一索引冲突
  260. var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  261. foreach (var s in items)
  262. {
  263. var code = (s.InvShelf ?? string.Empty).Trim();
  264. if (code.Length == 0)
  265. return (AdoS0ApiErrors.InvalidRequest("存在货架编码为空的明细行,请填写货架编码或删除该行"), new());
  266. if (code.Length > 100)
  267. return (AdoS0ApiErrors.InvalidRequest($"货架编码 '{code}' 超过 100 字符上限"), new());
  268. var descr = s.Descr?.Trim();
  269. if (descr is { Length: > 255 })
  270. return (AdoS0ApiErrors.InvalidRequest($"货架 '{code}' 的描述超过 255 字符上限"), new());
  271. var area = s.Area?.Trim();
  272. if (area is { Length: > 100 })
  273. return (AdoS0ApiErrors.InvalidRequest($"货架 '{code}' 的区域超过 100 字符上限"), new());
  274. if (!seen.Add(code))
  275. return (AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateDetailItem, $"货架编码重复:{code}"), new());
  276. normalized.Add(new AdoS0LocationShelfInputDto
  277. {
  278. InvShelf = code,
  279. Descr = string.IsNullOrEmpty(descr) ? null : descr,
  280. Area = string.IsNullOrEmpty(area) ? null : area
  281. });
  282. }
  283. return (null, normalized);
  284. }
  285. /// <summary>
  286. /// 由库位主表 + 当前租户统一赋值货架作用域字段(tenant_id 显式赋值,不依赖 AOP)。
  287. /// </summary>
  288. private static List<AdoS0LocationShelfMaster> BuildShelfEntities(
  289. AdoS0LocationMaster master, List<AdoS0LocationShelfInputDto> items, long tenantId, string? actingUser, DateTime now)
  290. {
  291. return items.Select(s => new AdoS0LocationShelfMaster
  292. {
  293. TenantId = tenantId,
  294. CompanyRefId = master.CompanyRefId,
  295. FactoryRefId = master.FactoryRefId,
  296. DomainCode = master.DomainCode,
  297. Location = master.Location,
  298. InvShelf = s.InvShelf,
  299. Descr = s.Descr,
  300. Area = s.Area,
  301. CreateUser = actingUser,
  302. CreateTime = now
  303. }).ToList();
  304. }
  305. /// <summary>
  306. /// 落库前断言:主表与全部货架的 TenantId 均等于当前租户且非空非 0(W-01 NULL 防线)。
  307. /// </summary>
  308. private static void AssertTenant(long tenantId, AdoS0LocationMaster master, List<AdoS0LocationShelfMaster> shelves)
  309. {
  310. if (tenantId <= 0 || master.TenantId != tenantId || shelves.Any(s => s.TenantId != tenantId))
  311. throw new InvalidOperationException("租户归属断言失败:主表/货架 TenantId 必须等于当前租户且非空。");
  312. }
  313. /// <summary>
  314. /// 写入异常映射:唯一键冲突 → 清晰业务错误;其余 → 500(事务已回滚)。
  315. /// </summary>
  316. private static IActionResult MapWriteException(Exception ex)
  317. {
  318. var msg = ex.Message + " " + (ex.InnerException?.Message ?? string.Empty);
  319. if (msg.Contains("Duplicate entry", StringComparison.OrdinalIgnoreCase) || msg.Contains("1062"))
  320. return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateDetailItem, "货架编码在同一库位内重复(唯一约束冲突),保存已回滚");
  321. return AdoS0ApiErrors.InternalServerError("库位与货架明细保存失败,已整体回滚");
  322. }
  323. }