ToolingLedgerService.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. using Admin.NET.Plugin.AiDOP.Entity;
  2. using Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
  3. namespace Admin.NET.Plugin.AiDOP.Manufacturing;
  4. /// <summary>
  5. /// S6 模工具台账管理 服务(FUNC-S6-004)。
  6. ///
  7. /// 数据源:aidopdev.MoldToolList(模工具台账),忠实复刻证据 sql.txt(select * from MoldToolList),
  8. /// 列映射见字段配置图:类型=lx / 名称=MoldName / 供应商名=gysmc / 数量=MoldCount / 功能应用名称=gnyymc /
  9. /// 模具类型编码=MoldTypeCode / 对应产品物料编码=product_material_code / 图纸号=product_drawing_number /
  10. /// 产品名称=product_name / 工序=process。lx 含 模具 / 工装 / 检具(同表同页同源,类型下拉可筛选)。
  11. ///
  12. /// list / type-options 为既有只读查询,本批不改其签名与返回结构。
  13. /// detail / create / update / delete 为 CRUD V1 新增能力,写字段严格限于 9 个白名单业务字段;
  14. /// gys / gysmc / MoldUsingDepartment / zrr / zt / sm / bypl / fj / xh / bh / gnyyid /
  15. /// MoldMaintenanceReleaseTime / ActiveMaintenanceMoldCount 语义未确认,本批不写。
  16. ///
  17. /// 租户:所有读写一律经 AidopTenantScope.ResolveOrThrow 取 JWT 可信租户,并在每条语句显式带
  18. /// tenant 条件;不信任前端传入的 tenantId,不依赖全局 QueryFilter(其对超管不生效)。
  19. /// </summary>
  20. [ApiDescriptionSettings(Order = 320, Description = "模工具台账管理")]
  21. [Route("api/S6ToolingLedger")]
  22. [NonUnify]
  23. public class ToolingLedgerService : IDynamicApiController, ITransient
  24. {
  25. private readonly ISqlSugarClient _db;
  26. private readonly UserManager _userManager;
  27. private readonly MoldToolReferenceChecker _refChecker;
  28. public ToolingLedgerService(ISqlSugarClient db, UserManager userManager, MoldToolReferenceChecker refChecker)
  29. {
  30. _db = db;
  31. _userManager = userManager;
  32. _refChecker = refChecker;
  33. }
  34. /// <summary>
  35. /// 模工具台账列表(只读分页查询)。
  36. /// </summary>
  37. [DisplayName("模工具台账列表")]
  38. [HttpGet("list")]
  39. public async Task<object> GetList([FromQuery] ToolingLedgerListInput 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 tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
  45. var where = new List<string> { "m.tenant_id = @TenantId" };
  46. var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
  47. if (!string.IsNullOrWhiteSpace(input.Name))
  48. {
  49. where.Add("m.MoldName LIKE @Name");
  50. pars.Add(new SugarParameter("@Name", $"%{input.Name.Trim()}%"));
  51. }
  52. if (!string.IsNullOrWhiteSpace(input.WorkGroup))
  53. {
  54. where.Add("m.gnyymc LIKE @WorkGroup");
  55. pars.Add(new SugarParameter("@WorkGroup", $"%{input.WorkGroup.Trim()}%"));
  56. }
  57. if (!string.IsNullOrWhiteSpace(input.UsageDept))
  58. {
  59. where.Add("m.MoldUsingDepartment LIKE @UsageDept");
  60. pars.Add(new SugarParameter("@UsageDept", $"%{input.UsageDept.Trim()}%"));
  61. }
  62. if (!string.IsNullOrWhiteSpace(input.Type))
  63. {
  64. where.Add("m.lx = @Type");
  65. pars.Add(new SugarParameter("@Type", input.Type.Trim()));
  66. }
  67. if (!string.IsNullOrWhiteSpace(input.MoldTypeCode))
  68. {
  69. where.Add("m.MoldTypeCode LIKE @MoldTypeCode");
  70. pars.Add(new SugarParameter("@MoldTypeCode", $"%{input.MoldTypeCode.Trim()}%"));
  71. }
  72. var whereSql = string.Join(" AND ", where);
  73. var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM MoldToolList m WHERE {whereSql}", pars);
  74. var list = await _db.Ado.SqlQueryAsync<ToolingLedgerListRow>(
  75. $"""
  76. SELECT
  77. m.id AS Id,
  78. m.lx AS Type,
  79. m.MoldName AS Name,
  80. m.gysmc AS SupplierName,
  81. m.MoldCount AS Qty,
  82. m.gnyymc AS FunctionAppName,
  83. m.MoldTypeCode AS MoldTypeCode,
  84. m.product_material_code AS ProductMaterialCode,
  85. m.product_drawing_number AS ProductDrawingNo,
  86. m.product_name AS ProductName,
  87. m.process AS Process
  88. FROM MoldToolList m
  89. WHERE {whereSql}
  90. ORDER BY m.id DESC
  91. LIMIT {pageSize} OFFSET {offset}
  92. """,
  93. pars);
  94. return new { total, page, pageSize, list };
  95. }
  96. /// <summary>
  97. /// 类型下拉选项(MoldToolList.lx distinct:模具 / 工装 / 检具,只读)。
  98. /// </summary>
  99. [DisplayName("模工具台账类型选项")]
  100. [HttpGet("type-options")]
  101. public async Task<object> GetTypeOptions()
  102. {
  103. var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
  104. var list = await _db.Ado.SqlQueryAsync<ToolingLedgerTypeOption>(
  105. """
  106. SELECT DISTINCT lx AS Val
  107. FROM MoldToolList
  108. WHERE tenant_id = @TenantId
  109. AND IFNULL(lx,'') <> ''
  110. ORDER BY lx
  111. """,
  112. new SugarParameter("@TenantId", tenantId));
  113. return list;
  114. }
  115. // ─────────────────────────── CRUD V1(FUNC-S6-004) ───────────────────────────
  116. /// <summary>
  117. /// 模工具台账详情(租户内)。不存在或不属当前租户一律 404,不泄露记录存在性。
  118. /// </summary>
  119. [DisplayName("模工具台账详情")]
  120. [HttpGet("detail/{id:int}")]
  121. public async Task<IActionResult> GetDetail(int id)
  122. {
  123. var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
  124. var entity = await LoadScopedAsync(id, tenantId);
  125. return entity == null ? NotFound() : new OkObjectResult(ToDetail(entity));
  126. }
  127. /// <summary>
  128. /// 新增模工具台账。tenant_id 只由服务端从 JWT 盖章,不接受任何客户端租户入参。
  129. /// </summary>
  130. [DisplayName("新增模工具台账")]
  131. [HttpPost("create")]
  132. public async Task<IActionResult> Create([FromBody] ToolingLedgerCreateInput input)
  133. {
  134. var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
  135. var type = NormalizeType(input.Type);
  136. var name = RequireText(input.Name, "名称");
  137. var qty = RequireQty(input.Qty);
  138. var code = RequireText(input.MoldTypeCode, "模具类型编码");
  139. // 友好预检;真正的并发唯一性由 DB 唯一索引 uk_MoldToolList_tenant_type_code 保证(见下方 catch)。
  140. var duplicated = await _db.Queryable<AdoS6MoldToolList>()
  141. .Where(x => x.TenantId == tenantId && x.MoldTypeCode == code)
  142. .AnyAsync();
  143. if (duplicated) return DuplicateCode(code);
  144. var entity = new AdoS6MoldToolList
  145. {
  146. TenantId = tenantId, // 服务端盖章,不取 DTO / query
  147. Lx = type,
  148. MoldName = name,
  149. MoldCount = qty,
  150. MoldTypeCode = code,
  151. Gnyymc = Trim(input.WorkGroup),
  152. ProductMaterialCode = Trim(input.ProductMaterialCode),
  153. ProductDrawingNumber = Trim(input.ProductDrawingNo),
  154. ProductName = Trim(input.ProductName),
  155. Process = Trim(input.Process)
  156. };
  157. try
  158. {
  159. entity.Id = await _db.Insertable(entity).ExecuteReturnIdentityAsync();
  160. }
  161. catch (Exception ex) when (IsDuplicateKeyViolation(ex))
  162. {
  163. // 并发下预检与插入之间存在 TOCTOU 窗口,由唯一索引兜底,转成同一业务错误,不外泄 SQL 异常。
  164. return DuplicateCode(code);
  165. }
  166. return new OkObjectResult(ToDetail(entity));
  167. }
  168. /// <summary>
  169. /// 编辑模工具台账(租户内)。MoldTypeCode 与 tenant_id 均不可修改(入参类型上即不接受)。
  170. /// </summary>
  171. [DisplayName("编辑模工具台账")]
  172. [HttpPut("update/{id:int}")]
  173. public async Task<IActionResult> Update(int id, [FromBody] ToolingLedgerUpdateInput input)
  174. {
  175. var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
  176. var entity = await LoadScopedAsync(id, tenantId);
  177. if (entity == null) return NotFound();
  178. var type = NormalizeType(input.Type);
  179. var name = RequireText(input.Name, "名称");
  180. var qty = RequireQty(input.Qty);
  181. var workGroup = Trim(input.WorkGroup);
  182. var materialCode = Trim(input.ProductMaterialCode);
  183. var drawingNo = Trim(input.ProductDrawingNo);
  184. var productName = Trim(input.ProductName);
  185. var process = Trim(input.Process);
  186. // 显式 SET + 显式 WHERE(id + tenant_id):
  187. // ① 杜绝裸 UPDATE ... WHERE id=@id;② 只写 8 个白名单列,
  188. // MoldTypeCode / tenant_id / 全部 DEFERRED 列不出现在 SET 子句中。
  189. var affected = await _db.Updateable<AdoS6MoldToolList>()
  190. .SetColumns(x => new AdoS6MoldToolList
  191. {
  192. Lx = type,
  193. MoldName = name,
  194. MoldCount = qty,
  195. Gnyymc = workGroup,
  196. ProductMaterialCode = materialCode,
  197. ProductDrawingNumber = drawingNo,
  198. ProductName = productName,
  199. Process = process
  200. })
  201. .Where(x => x.Id == id && x.TenantId == tenantId)
  202. .ExecuteCommandAsync();
  203. if (affected <= 0) return NotFound();
  204. entity.Lx = type;
  205. entity.MoldName = name;
  206. entity.MoldCount = qty;
  207. entity.Gnyymc = workGroup;
  208. entity.ProductMaterialCode = materialCode;
  209. entity.ProductDrawingNumber = drawingNo;
  210. entity.ProductName = productName;
  211. entity.Process = process;
  212. return new OkObjectResult(ToDetail(entity));
  213. }
  214. /// <summary>
  215. /// 删除模工具台账(租户内物理删除;表无软删列)。被下游引用时拒绝删除,不级联、不清空引用。
  216. /// </summary>
  217. [DisplayName("删除模工具台账")]
  218. [HttpDelete("delete/{id:int}")]
  219. public async Task<IActionResult> Delete(int id)
  220. {
  221. var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
  222. var entity = await LoadScopedAsync(id, tenantId);
  223. if (entity == null) return NotFound();
  224. var reference = await _refChecker.ReferencesAsync(tenantId, entity.MoldTypeCode);
  225. if (reference is { } r)
  226. return Problem(409, $"该模工具已被 {r.Count} 条{r.Table}引用,无法删除");
  227. var affected = await _db.Deleteable<AdoS6MoldToolList>()
  228. .Where(x => x.Id == id && x.TenantId == tenantId)
  229. .ExecuteCommandAsync();
  230. if (affected <= 0) return NotFound();
  231. return new OkObjectResult(new { message = "删除成功" });
  232. }
  233. // ─────────────────────────── 内部辅助 ───────────────────────────
  234. /// <summary>租户内按主键取单条;不存在或属于其他租户一律返回 null(调用方统一转 404,不泄露存在性)。</summary>
  235. private Task<AdoS6MoldToolList> LoadScopedAsync(int id, long tenantId)
  236. => _db.Queryable<AdoS6MoldToolList>()
  237. .Where(x => x.Id == id && x.TenantId == tenantId)
  238. .FirstAsync();
  239. /// <summary>
  240. /// 业务错误结果。刻意用 ObjectResult 显式给状态码,而不是 Oops.Oh(...).StatusCode(n):
  241. /// 本服务是 [NonUnify] 的 IDynamicApiController,实测该路径下 Furion 友好异常一律落 400,
  242. /// StatusCode(409/404) 不生效。校验类错误仍走 Oops.Oh(400 语义正确)。
  243. /// body 为纯中文字符串,与既有 S6 错误响应形态一致,前端 request 拦截器可直接提取展示。
  244. /// </summary>
  245. private static IActionResult Problem(int statusCode, string message)
  246. => new ObjectResult(message) { StatusCode = statusCode };
  247. private static IActionResult NotFound() => Problem(404, "模工具台账记录不存在");
  248. private static IActionResult DuplicateCode(string code) =>
  249. Problem(409, $"模具类型编码「{code}」已存在,请更换后重试");
  250. /// <summary>类型固定枚举校验(不依赖存量 DISTINCT,空租户同样可创建首条)。</summary>
  251. private static string NormalizeType(string? value)
  252. {
  253. var type = value?.Trim();
  254. if (string.IsNullOrEmpty(type)) throw Oops.Oh("类型不能为空");
  255. if (!ToolingLedgerConst.Types.Contains(type))
  256. throw Oops.Oh($"类型只能是 {string.Join(" / ", ToolingLedgerConst.Types)}");
  257. return type;
  258. }
  259. private static string RequireText(string? value, string label)
  260. {
  261. var text = value?.Trim();
  262. if (string.IsNullOrEmpty(text)) throw Oops.Oh($"{label}不能为空");
  263. return text;
  264. }
  265. /// <summary>数量必填且非负;注意 0 是合法值,不得当成空值。</summary>
  266. private static int RequireQty(int? value)
  267. {
  268. if (value == null) throw Oops.Oh("数量不能为空");
  269. if (value < 0) throw Oops.Oh("数量不能为负数");
  270. return value.Value;
  271. }
  272. /// <summary>空白串归一为 null,避免把历史 NULL 污染成空串。</summary>
  273. private static string? Trim(string? value)
  274. {
  275. var text = value?.Trim();
  276. return string.IsNullOrEmpty(text) ? null : text;
  277. }
  278. /// <summary>
  279. /// 识别唯一键冲突。按驱动异常文本判定(MySQL 1062 / "Duplicate entry"),
  280. /// 不额外引入 MySqlConnector 包引用,且对被 SqlSugar 包装的异常链同样有效。
  281. /// </summary>
  282. private static bool IsDuplicateKeyViolation(Exception ex)
  283. {
  284. for (var e = ex; e != null; e = e.InnerException)
  285. {
  286. var message = e.Message;
  287. if (string.IsNullOrEmpty(message)) continue;
  288. if (message.Contains("Duplicate entry", StringComparison.OrdinalIgnoreCase)) return true;
  289. if (message.Contains("uk_MoldToolList_tenant_type_code", StringComparison.OrdinalIgnoreCase)) return true;
  290. }
  291. return false;
  292. }
  293. private static ToolingLedgerDetailDto ToDetail(AdoS6MoldToolList e) => new()
  294. {
  295. Id = e.Id,
  296. Type = e.Lx,
  297. Name = e.MoldName,
  298. Qty = e.MoldCount,
  299. MoldTypeCode = e.MoldTypeCode,
  300. WorkGroup = e.Gnyymc,
  301. ProductMaterialCode = e.ProductMaterialCode,
  302. ProductDrawingNo = e.ProductDrawingNumber,
  303. ProductName = e.ProductName,
  304. Process = e.Process
  305. };
  306. }