AdoS0LocationsController.cs 17 KB

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