StocktakeLabelConfirmService.cs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
  2. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  3. /// <summary>
  4. /// S5 盘点标签确认 只读 list 服务。
  5. ///
  6. /// 数据源:aidopdev.MissedPrint(IsTag=1) LEFT JOIN ItemMaster(物料名称/规格/单位)。
  7. /// 参考本仓既有 MissedPrint 直读只读投影先例:LabelQueryService / StockQueryService / PendingInspectionService。
  8. ///
  9. /// 本服务仅 SELECT:无新增/编辑/删除/确认写回/批量确认/反确认/库存事务/状态流转;扁平列表,无 detail。
  10. /// 派生列:diffQty=CompQty-Qty;status(Status='Q'→冻结);stocktakeResult(CompQty&lt;&gt;Qty→有差异/正常)。
  11. ///
  12. /// 【认证】类级已去 [AllowAnonymous]:原先该接口可被**完全未认证**调用
  13. /// (实测匿名 GET —— 无 Authorization 头、credentials:'omit' 连 cookie 也不发 —— 返回 HTTP 200)。
  14. /// 当时 IsTag=1 恰好 0 行故未造成实际泄漏,但端点是公开的,一旦出现盘点标签数据即形成匿名读取。
  15. /// 现收敛为需认证访问。
  16. ///
  17. /// 【仍未解决 · 跟踪项】租户边界。查询仍是 WHERE IsTag=1,无 Tenant/Domain 条件。
  18. /// 原因已从「无法过滤」变成「本服务无认证上下文可取租户」:MissedPrint.tenant_id 曾实测 152 行全为 NULL,
  19. /// 现已由生成标签写入 + 1.0.442 一次性回填补齐,008/013 已改按 m.tenant_id 过滤;
  20. /// 本服务未一并改造,是因为它当前 IsTag=1 恒为 0 行(DEAD FUNCTION),
  21. /// 改造留待其恢复业务时与 UserManager 注入一起做。
  22. /// 现状:**Anonymous access CLOSED,tenant isolation 未 CLOSED**。
  23. /// </summary>
  24. [ApiDescriptionSettings(Order = 308, Description = "盘点标签确认")]
  25. [Route("api/S5StocktakeLabelConfirm")]
  26. [NonUnify]
  27. public class StocktakeLabelConfirmService : IDynamicApiController, ITransient
  28. {
  29. private readonly ISqlSugarClient _db;
  30. public StocktakeLabelConfirmService(ISqlSugarClient db)
  31. {
  32. _db = db;
  33. }
  34. /// <summary>
  35. /// 盘点标签确认列表(只读分页查询)。
  36. /// </summary>
  37. [DisplayName("盘点标签确认列表")]
  38. [HttpGet("list")]
  39. public async Task<object> GetList([FromQuery] StocktakeLabelConfirmListInput input)
  40. {
  41. var page = input.Page <= 0 ? 1 : input.Page;
  42. var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
  43. var offset = (page - 1) * pageSize;
  44. var where = new List<string> { "m.IsTag = 1" };
  45. var pars = new List<SugarParameter>();
  46. if (!string.IsNullOrWhiteSpace(input.Location))
  47. {
  48. where.Add("m.Location LIKE @Location");
  49. pars.Add(new SugarParameter("@Location", $"%{input.Location.Trim()}%"));
  50. }
  51. if (!string.IsNullOrWhiteSpace(input.Shelf))
  52. {
  53. where.Add("m.Shelf LIKE @Shelf");
  54. pars.Add(new SugarParameter("@Shelf", $"%{input.Shelf.Trim()}%"));
  55. }
  56. if (!string.IsNullOrWhiteSpace(input.ItemNum))
  57. {
  58. where.Add("m.ItemNum LIKE @ItemNum");
  59. pars.Add(new SugarParameter("@ItemNum", $"%{input.ItemNum.Trim()}%"));
  60. }
  61. if (!string.IsNullOrWhiteSpace(input.LotSerial))
  62. {
  63. where.Add("m.LotSerial LIKE @LotSerial");
  64. pars.Add(new SugarParameter("@LotSerial", $"%{input.LotSerial.Trim()}%"));
  65. }
  66. if (string.Equals(input.InvStatus, "有差异", StringComparison.Ordinal))
  67. {
  68. where.Add("IFNULL(m.CompQty,0) <> IFNULL(m.Qty,0)");
  69. }
  70. else if (string.Equals(input.InvStatus, "正常", StringComparison.Ordinal))
  71. {
  72. where.Add("IFNULL(m.CompQty,0) = IFNULL(m.Qty,0)");
  73. }
  74. var whereSql = string.Join(" AND ", where);
  75. var total = await _db.Ado.GetIntAsync(
  76. $"SELECT COUNT(1) FROM MissedPrint m WHERE {whereSql}", pars);
  77. var list = await _db.Ado.SqlQueryAsync<StocktakeLabelConfirmListRow>(
  78. $"""
  79. SELECT
  80. m.RecID AS Id,
  81. m.ItemNum AS MaterialCode,
  82. i.Descr AS MaterialName,
  83. i.Descr1 AS Spec,
  84. m.Location AS Location,
  85. m.Shelf AS Shelf,
  86. m.FirmString2 AS ActualLocation,
  87. m.FirmString4 AS ActualShelf,
  88. m.LotSerial AS BatchNo,
  89. i.UM AS Unit,
  90. m.Qty AS LabelQty,
  91. m.CompQty AS ActualQty,
  92. IFNULL(m.CompQty,0) - IFNULL(m.Qty,0) AS DiffQty,
  93. CASE WHEN m.Status='Q' THEN '冻结' ELSE IFNULL(m.Status,'') END AS Status,
  94. CASE WHEN IFNULL(m.CompQty,0) <> IFNULL(m.Qty,0) THEN '有差异' ELSE '正常' END AS StocktakeResult,
  95. m.UpdateUser AS StocktakePerson,
  96. m.UpdateTime AS StocktakeTime,
  97. m.BarCode AS BarCode
  98. FROM MissedPrint m
  99. LEFT JOIN ItemMaster i ON i.Domain = m.Domain AND i.ItemNum = m.ItemNum
  100. WHERE {whereSql}
  101. ORDER BY {BuildOrderBy(input.OrderBy, input.OrderDir)}
  102. LIMIT {pageSize} OFFSET {offset}
  103. """,
  104. pars);
  105. return new { total, page, pageSize, list };
  106. }
  107. /// <summary>
  108. /// 库位下拉选项(MissedPrint IsTag=1 的 Location distinct,只读)。
  109. /// </summary>
  110. [DisplayName("盘点标签确认库位选项")]
  111. [HttpGet("locations")]
  112. public async Task<object> GetLocations()
  113. {
  114. var list = await _db.Ado.SqlQueryAsync<StocktakeLabelConfirmLocationOption>(
  115. """
  116. SELECT DISTINCT Location AS Val
  117. FROM MissedPrint
  118. WHERE IsTag = 1 AND Location IS NOT NULL AND Location <> ''
  119. ORDER BY Location
  120. """);
  121. return list;
  122. }
  123. /// <summary>
  124. /// 排序白名单:仅允许按已展示列排序,杜绝 SQL 注入;非法字段回落默认。
  125. /// </summary>
  126. private static string BuildOrderBy(string? orderBy, string? orderDir)
  127. {
  128. var column = orderBy switch
  129. {
  130. "itemNum" => "m.ItemNum",
  131. "location" => "m.Location",
  132. "shelf" => "m.Shelf",
  133. "lotSerial" => "m.LotSerial",
  134. "qty" => "m.Qty",
  135. "compQty" => "m.CompQty",
  136. "qtyDiff" => "(IFNULL(m.CompQty,0) - IFNULL(m.Qty,0))",
  137. "invStatus" => "(CASE WHEN IFNULL(m.CompQty,0) <> IFNULL(m.Qty,0) THEN 1 ELSE 0 END)",
  138. "updateTime" => "m.UpdateTime",
  139. _ => "m.UpdateTime",
  140. };
  141. var direction = string.Equals(orderDir, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
  142. return $"{column} {direction}, m.RecID DESC";
  143. }
  144. }