1. 这个需求到底在算什么
最近接了个活儿,某机械制造企业的质检部给我提了个需求:要在现有的检测数据系统上,做一个基于 .Net API 的统计接口,专门用来汇总图纸上标注的值、上公差、下公差,然后跟现场实测数据做比对。一开始我以为只是简单查个数据库汇总一下,等拿到他们的原始表格和图纸标注规则后,才发现这里面有几个坑必须提前讲清楚。
先说这个“统计”到底统计什么。机械加工图纸上,每个被检测的尺寸都会有一个名义尺寸(也就是图纸上那个基准值),比如一根轴的外径标注为 25.000 mm,然后在这个值旁边标上公差:上偏差 +0.021 / 0。意思就是加工出来的实际尺寸只要落在 25.000 到 25.021 之间,就算合格品。这个 25.000 就是“标注的值”,+0.021 是上公差,0 是下公差。
但如果图纸上标的是 25.000 +0.021/-0.008 呢?实际尺寸的允许范围就变成了 24.992 到 25.021,足足跨了 0.029 mm。这个区间计算如果做错一步,后面所有统计结果全都会翻车。所以接口的核心不只是“查数据”,而是要先把“区间计算规则”写对。
这类需求通常出现在三类场景里:一是车间现场的终检报表自动生成,二是质量管理部门的批量数据分析,三是把检测数据接入 SPC 统计过程控制系统。无论哪一种,核心都是把图纸标注的规格要求,转成可计算的上限值和下限值,再拿来和测量数据比较。
这个系统适合谁参考?如果你正在做工厂信息化、质检系统、MES 系统里的检测数据接口,或者纯粹好奇公差判定怎么做,这篇内容对你应该有直接帮助。下面我按照我实际落地这套接口的顺序,把涉及的关键点一个个拆开讲。
2. 接口设计思路与数据模型
2.1 从质检表格到 Web API,中间差的不是代码
质检部门原本的工作流是这样的:检测员用游标卡尺、千分尺或三坐标测量机测出数据,手写填入纸质表单,或者录进 Excel 表格,然后由专人汇总、判定、生成报告。流程本身没大问题,但口径不统一,有人按“实测值落在公差带内”算合格,有人按“实测值 - 名义值后的偏差落在上下偏差之间”算合格,最后报表数据对不上,扯皮。
所以当我决定写 .Net API 时,第一步不是开 IDE,而是把判定口径跟工艺那边对齐。我最终选的口径是:实际尺寸落在 [名义值 + 下偏差,名义值 + 上偏差] 区间内即为合格。注意,这里所有计算用 decimal,不用 double,原因后面单独讲。
接口架构上,我用了经典的 ASP.NET Core Web API 三层结构:Controller 只做参数接收和响应包装,Service 层处理统计计算逻辑,Repository 层封装 EF Core 数据库访问。因为质检数据往往需要跟已有的 ERP 或 MES 做集成,用标准 RESTful API 的方式,对方无论用 Java、Python 还是直接拿 Excel VBA 调用都方便。
2.2 数据模型:把图纸标注拆开存
表结构是这套东西的地基。我建了三个核心表:
| 表名 | 用途 | 关键字段示例 |
|---|---|---|
| SpecItem(规格项) | 记录图纸上的标注信息 | Id, DrawingCode, MarkValue, UpperTolerance, LowerTolerance, Unit |
| MeasureRecord(测量记录) | 存储每次实测数据 | Id, SpecId, MeasuredValue, OperatorName, MeasureTime |
| SummaryResult(统计结果) | 缓存统计输出,减少重复计算 | Id, SpecId, TotalCount, PassCount, PassRate, MaxValue, MinValue, AverageValue, CalcTime |
这里最容易被忽略的是:上公差和下公差在数据库里必须存成带符号的数值,而不是“公差值”。比如图纸上写的是 25.000 +0.021/-0.008,数据库里就应该存 UpperTolerance = 0.021,LowerTolerance = -0.008。有些人图省事把下公差存成正的 0.008,等做判断时再去套符号逻辑,十有八九会搞混。
另外还有一个约定要提前定好:上下公差同为正、同为负、一正一负、甚至零公差,这三种情况都必须能正确处理。0 的公差并不意味着不用判断,而是代表“只允许等于某个值”,这在刀口尺、环规这类标准量具里很常见。所以接口里不能因为 UpperTolerance 或 LowerTolerance 为 0 就跳过判断,必须形成一套统一的计算规则。
2.3 请求与响应格式:接口不只是“一个接口”
基于实际使用场景,我把接口拆成两个:
一个是按规格项查统计汇总的接口,用于前端报表展示;另一个是提交测量数据后实时返回该次测量是否合格的接口,用于现场平板快速录入。前者我设计成 GET 请求,返回某个零件图号下所有规格的统计指标;后者是 POST 请求,一次可以批量提交几十个测量点。
我给出的响应格式统一用了这种结构:
{ "specId": 1024, "markValue": 25.000, "upperTolerance": 0.021, "lowerTolerance": -0.008, "upperLimit": 25.021, "lowerLimit": 24.992, "totalCount": 300, "passCount": 287, "failCount": 13, "passRate": 0.9567, "maxValue": 25.031, "minValue": 24.986, "averageValue": 25.003, "range": 0.045 }response 里同时返回原始标注数据和计算后区间,目的是方便前端报表直接展示从“图纸要求”到“实测结果”的完整链路,不用再写第二个查询接口去拼原始数据。
3. 核心算法与关键代码实现
3.1 单点合格判定:一眼看出产品过不过
逻辑本身不复杂:实测值 value 和上边界 upperLimit、下边界 lowerLimit 比较。没有任何一个边界能被吃掉,大于上边界算“超上差”,小于下边界算“超下差”,只有同时满足才返回“合格”。
我不建议把这条逻辑散落在各个 Controller 方法里,而是抽成一个独立的静态方法,因为后续做批量统计、实时判定、历史数据回看,都要反复调用它。保持“一处定义,处处复用”。
3.2 统计指标怎么选
这是整个方案里最需要跟需求方确认的一步,因为不同角色想要的东西完全不一样:
- 操作工关心:这个班次的合格率是多少,哪几个尺寸老超差,得赶紧调机。
- 质检主管关心:按零件图号汇总,哪张图纸对应的产品整体质量最差,要不要评审工艺。
- 工艺工程师关心:最大、最小值离公差带边缘还有多远,现有加工能力够不够。
所以我的统计结果里不只是合格率,还输出最大值、最小值、平均值和极差。平均值能反映加工中心的偏移倾向,极差反映稳定性。如果数据量到了几百个,还可以顺手把标准差和西格玛水平也算出来,放到扩展字段里,前端想用就有,不想用也不碍事。
注意,统计指标计算里有一个隐藏陷阱:如果某一检测项的实测记录为 0 条,接口不能直接返回 0 或者抛空引用,而应该返回一个“无数据”的状态码或提示,否则前端会呈现一个“合格率 0%”的假象,误导决策。
3.3 代码实现要点
核心判定逻辑我写成了这样一个服务类:
public class ToleranceStatisticsService { private readonly ApplicationDbContext _dbContext; public ToleranceStatisticsService(ApplicationDbContext dbContext) { _dbContext = dbContext; } public async Task<ToleranceSummaryDto> CalculateSummaryAsync(int specId, CancellationToken ct) { var spec = await _dbContext.SpecItems .AsNoTracking() .FirstOrDefaultAsync(s => s.Id == specId, ct); if (spec == null) throw new KeyNotFoundException($"规格项 {specId} 不存在"); decimal upperLimit = spec.MarkValue + spec.UpperTolerance; decimal lowerLimit = spec.MarkValue + spec.LowerTolerance; var records = await _dbContext.MeasureRecords .AsNoTracking() .Where(r => r.SpecId == specId) .Select(r => r.MeasuredValue) .ToListAsync(ct); var summary = new ToleranceSummaryDto { SpecId = spec.Id, MarkValue = spec.MarkValue, UpperTolerance = spec.UpperTolerance, LowerTolerance = spec.LowerTolerance, UpperLimit = upperLimit, LowerLimit = lowerLimit }; if (records.Count == 0) { summary.NoData = true; return summary; } int passCount = 0; int failAboveCount = 0; int failBelowCount = 0; decimal sum = 0; decimal maxValue = decimal.MinValue; decimal minValue = decimal.MaxValue; foreach (var value in records) { if (value >= lowerLimit && value <= upperLimit) { passCount++; } else if (value > upperLimit) { failAboveCount++; } else if (value < lowerLimit) { failBelowCount++; } sum += value; if (value > maxValue) maxValue = value; if (value < minValue) minValue = value; } summary.TotalCount = records.Count; summary.PassCount = passCount; summary.FailAboveCount = failAboveCount; summary.FailBelowCount = failBelowCount; summary.PassRate = Math.Round((decimal)passCount / records.Count, 4); summary.MaxValue = maxValue; summary.MinValue = minValue; summary.AverageValue = Math.Round(sum / records.Count, 4); summary.Range = maxValue - minValue; return summary; } }循环里的判断顺序有一个细节:我用 else if 而不是两个独立 if,因为一个值不可能同时超上差和超下差,写成独立 if 虽然结果没错,但会多做一次无谓的比较。习惯上我还会把“合格”分支放在第一个,语义更直观。
再看 Controller 层怎么接:
[ApiController] [Route("api/[controller]")] public class ToleranceStatisticsController : ControllerBase { private readonly ToleranceStatisticsService _service; public ToleranceStatisticsController(ToleranceStatisticsService service) { _service = service; } [HttpGet("{specId:int}/summary")] public async Task<IActionResult> GetSummary(int specId, CancellationToken ct) { try { var result = await _service.CalculateSummaryAsync(specId, ct); return Ok(result); } catch (KeyNotFoundException ex) { return NotFound(ex.Message); } } }Controller 本身几乎没有逻辑,全部委托给 Service。这样写带来的好处是:后面如果要加缓存、加权限、加多数据源,都不用碰控制器代码,改动范围收敛到服务层内部。
3.4 批量统计:别用循环调单点接口
实际生产环境里,质检员一次要统计的是整张图纸、甚至整批零件,可能有几十上百个规格项。如果前端拿到所有规格 ID 后逐个循环调用上面的单点接口,光 HTTP 连接建立的损耗就够喝一壶。
正确做法是提供一个按零件图号批量统计的接口,在数据库层面一次性把该图号下所有规格项和测量记录取出来,然后在内存里分组聚合。我用了一个非常朴素但好用的写法:先用 GroupBy 把测量记录按 SpecId 分组,再逐组套用同一套判定逻辑。
public async Task<List<ToleranceSummaryDto>> CalculateByDrawingAsync( string drawingCode, DateTime startTime, DateTime endTime, CancellationToken ct) { var specs = await _dbContext.SpecItems .AsNoTracking() .Where(s => s.DrawingCode == drawingCode) .ToListAsync(ct); if (specs.Count == 0) return new List<ToleranceSummaryDto>(); var specIds = specs.Select(s => s.Id).ToList(); var groupedRecords = await _dbContext.MeasureRecords .AsNoTracking() .Where(r => specIds.Contains(r.SpecId) && r.MeasureTime >= startTime && r.MeasureTime <= endTime) .GroupBy(r => r.SpecId) .Select(g => new { SpecId = g.Key, Values = g.Select(x => x.MeasuredValue).ToList() }) .ToListAsync(ct); var resultList = new List<ToleranceSummaryDto>(); foreach (var spec in specs) { var values = groupedRecords.FirstOrDefault(g => g.SpecId == spec.Id)?.Values ?? new List<decimal>(); // 复用单规格统计逻辑 resultList.Add(BuildSummary(spec, values)); } return resultList; }这里有一个值得注意的性能细节:在 EF Core 里,把大量记录加载到内存再用 LINQ 聚合,和直接在数据库里完成 GroupBy + 聚合,性能差异在数据量超过十万条时非常明显。我后来把纯统计部分改写成了原生 SQL 投影,因为 EF Core 对复杂 GroupBy 的翻译有时会产生低效 SQL。如果各位手头数据量不大,上面的写法够用;数据量大,建议直接在 SQL 里算好最大值、最小值、平均值再映射到 DTO。
4. 实操过程与踩坑实录
4.1 需求口径:先跟质检人员对齐“临界值”
第一个坑出现在判定边界上。检测值刚好等于上界限值,算合格还是不合格?工艺那边一开始自己都没想明白。按国家标准,公差带的边界是包含的,即实测值等于上限或下限时算合格。这个结论不写进接口的话,现场会出现“看起来一样的数据,有人算合格有人算不合格”的情况。
我在代码里用的是>= lowerLimit && <= upperLimit,明确包含了边界值。同时我在接口注释里也写清楚了这一条,避免后续维护的人改错。这件事提醒我:公差统计接口的技术难点不只在代码,把业务规则先文字化、书面化,比写一千行代码更有用。
4.2 decimal 精度:double 算公差就是灾难
机械加工的公差经常到微米级,比如某个配合尺寸标注 12.005 +0.008/-0.004。如果数据库字段用的是 float 或 double,计算 12.005 + 0.008 时,计算机底层二进制转换会产生类似 12.012999999 的结果,表面上问题不大,但一旦有几百条数据要判断边界,就可能出现“明明等边界却判定超差”的灵异现象。
我在推荐表结构时明确要求所有尺寸、公差、实测值字段一律使用 decimal(在 SQL Server 里对应 decimal(18, 6) 或 decimal(18, 8))。这不是洁癖,而是真实踩过坑后的血的教训:某次统计报告里,所有 12.013 的实测值全部被判定超上差,原因就是 12.005 + 0.008 用 double 算出来是 12.012999999999999。换成 decimal 之后,问题立刻消失。
4.3 性能:无索引统计十秒变五百毫秒
刚开始联调,我测了一个有 8 万多条测量记录的零件图号统计,结果用时超过 10 秒。用数据库分析工具一看,罪魁祸首是 MeasureRecord 表没有建立 SpecId + MeasureTime 的联合索引。查询里全部走全表扫描,每次统计都是硬扫。
解法很简单,加一个复合索引就行:
CREATE INDEX IX_MeasureRecord_SpecId_MeasureTime ON MeasureRecord(SpecId, MeasureTime);加了索引后,同样的统计降到几百毫秒。如果数据到了百万级,还可以考虑对 MeasureRecord 表做按月分区,但这属于后话了,一般车间系统到不了这个量级。
4.4 重复提交:一个防抖引发的现场事故
现场质检员的操作习惯是:平板录入数据后,按钮双击或网络延迟时会连续点两三次。为了图省事,我最初的接口没有做幂等处理,结果同一条测量记录在数据库里出现了好几遍,统计出来的合格率严重失真。
后来我在 MeasureRecord 表加了一个自然唯一键:零件批次号 + 规格项 ID + 设备序列号 + 测量时间周期。这样即使同一台设备在同一个周期内提交了重复数据,数据库层面也能直接拦住。配合 API 层用一个简单的请求幂等键,问题就彻底解决了。
4.5 常见问题速查
| 现象 | 可能原因 | 解决建议 |
|---|---|---|
| 合格率比手工统计低 | 实数精度用成了 double | 统一改成 decimal |
| 数据量一大就卡死 | 缺少 SpecId + MeasureTime 联合索引 | 补索引,必要时 SQL 端聚合 |
| 重复数据导致统计翻倍 | 接口没有幂等防重 | 表加唯一键 + 前端防重复提交 |
| 边界值对不上 | 判定时用了 > 而不是 >= | 明确包含边界并写成注释 |
| 统计结果跟 Excel 不一致 | 分组维度不同,比如漏了规格项 | 统一按 SpecId 最小分组单元 |
5. 接口还能怎么扩展
这套接口最基础的能力已经覆盖了“标注的值 + 上下公差 + 实测数据”的统计闭环。实际用下来,客户往往会在这个基础上继续提新的需求,我也把其中几个做了出来:
一个是超差实时预警。测量数据在录入时就做单点判定,一旦超差,立即把结果推送给车间看板或者质检员的企业微信/钉钉群。实现上并不难,在 POST 提交接口里加一个订阅发布机制即可,关键是不要让推送逻辑阻塞正常写入。
另一个是 SPC 能力。传统的休哈特控制图需要均值、极差、标准差等数据,而这套统计接口恰好已经把原始指标算好了。做一个控制图数据专用接口,把按时间分组的均值和极差给过去,前端画图就是现成的。
再有一个是导出。质检报表最终还是要变成 Excel 或者 PDF。数据从这套 API 拿,模板交给报表服务渲染,比直接在系统里爬数据要干净得多。
6. 写在最后的一些体会
连着做完整套接口,我最深的感受是:公差统计接口真正的价值,不在于你会写多少种聚合查询,而在于你能不能把图纸上那一串看似简单的标记,转化成机器可判定的精确规则。小数点后第四位的精度、边界值包含与否、重复数据的拦截,每一个细节漏掉,现场的质检数据就会失真。
我个人建议,任何接到类似需求的朋友,开工前一定抽出半天时间去车间看一次实际操作,看看质检员是怎么读图纸、怎么录入数据的。你会发现很多接口设计时想不到的规则,都藏在他们的日常操作习惯里。
最后再分享一个小技巧:接口里凡是涉及公差计算的公共方法,都加上详细的 XML 注释,把“上公差、下公差必须是带符号数值”“边界值按合格处理”这类约定写清楚。这套系统过半年换人维护时,这些注释比任何文档都管用。