AdoS0LocationsController.cs 14 KB

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