从零搭建灌装监控系统(十):生产追溯,条码触发自动记录

生产追溯:条码触发自动记录

这是「从零搭建灌装监控系统」系列第10篇。上一篇把 SQLite 和 FreeSql 接通了,但数据库不会自动知道一批产品什么时候开始、什么时候结束。这篇从 PLC 轮询到的条码变化入手,实现去重、记录快照和异常保护,让"看见一个新条码"真正变成一条生产记录。


条码读到了,为什么数据库里有三条

第一次把条码接到轮询循环时,代码很直观:每次收到设备状态,就读取条码;只要条码不为空,就插入一条记录。

测试人员扫入一个条码,界面上很快出现了记录。看起来没问题。过了一会儿打开数据库,发现同一个条码有几十条。

这不是数据库重复提交,也不是扫描枪坏了。轮询本来就会持续运行,200ms 一次意味着同一个条码在传感器保持期间会被读到很多次。条码是状态,入库动作是事件。 如果没有把这两个概念分开,重复记录只是时间问题。

这一篇做的事情不复杂:保存上一次处理过的条码,只有从"没有条码"到"出现新条码"或从"旧条码"到"新条码"时才创建记录,同时把当时的工艺数据冻结下来。


先区分状态和事件

设备状态可以这样理解:

text 复制代码
当前状态:Barcode = B20260923001
下一次轮询:Barcode = B20260923001
再下一次轮询:Barcode = B20260923001

这是同一个状态,不是三个生产事件。只有下面的变化才值得触发入库:

text 复制代码
空字符串 -> B20260923001       开始记录
B20260923001 -> B20260923001   不处理
B20260923001 -> B20260923002   新批次记录
B20260923001 -> 空字符串       条码离开,结束当前状态

代码中的 _lastBarcode 就是一个小型状态机记忆。它不是数据库,也不是缓存系统,只负责回答一个问题:这次看到的条码,和上次相比有没有变化?


#mermaid-svg-fPJhkBcVVcUQeZxu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fPJhkBcVVcUQeZxu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fPJhkBcVVcUQeZxu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fPJhkBcVVcUQeZxu .error-icon{fill:#552222;}#mermaid-svg-fPJhkBcVVcUQeZxu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fPJhkBcVVcUQeZxu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fPJhkBcVVcUQeZxu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fPJhkBcVVcUQeZxu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fPJhkBcVVcUQeZxu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fPJhkBcVVcUQeZxu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fPJhkBcVVcUQeZxu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fPJhkBcVVcUQeZxu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fPJhkBcVVcUQeZxu .marker.cross{stroke:#333333;}#mermaid-svg-fPJhkBcVVcUQeZxu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fPJhkBcVVcUQeZxu p{margin:0;}#mermaid-svg-fPJhkBcVVcUQeZxu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fPJhkBcVVcUQeZxu .cluster-label text{fill:#333;}#mermaid-svg-fPJhkBcVVcUQeZxu .cluster-label span{color:#333;}#mermaid-svg-fPJhkBcVVcUQeZxu .cluster-label span p{background-color:transparent;}#mermaid-svg-fPJhkBcVVcUQeZxu .label text,#mermaid-svg-fPJhkBcVVcUQeZxu span{fill:#333;color:#333;}#mermaid-svg-fPJhkBcVVcUQeZxu .node rect,#mermaid-svg-fPJhkBcVVcUQeZxu .node circle,#mermaid-svg-fPJhkBcVVcUQeZxu .node ellipse,#mermaid-svg-fPJhkBcVVcUQeZxu .node polygon,#mermaid-svg-fPJhkBcVVcUQeZxu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fPJhkBcVVcUQeZxu .rough-node .label text,#mermaid-svg-fPJhkBcVVcUQeZxu .node .label text,#mermaid-svg-fPJhkBcVVcUQeZxu .image-shape .label,#mermaid-svg-fPJhkBcVVcUQeZxu .icon-shape .label{text-anchor:middle;}#mermaid-svg-fPJhkBcVVcUQeZxu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fPJhkBcVVcUQeZxu .rough-node .label,#mermaid-svg-fPJhkBcVVcUQeZxu .node .label,#mermaid-svg-fPJhkBcVVcUQeZxu .image-shape .label,#mermaid-svg-fPJhkBcVVcUQeZxu .icon-shape .label{text-align:center;}#mermaid-svg-fPJhkBcVVcUQeZxu .node.clickable{cursor:pointer;}#mermaid-svg-fPJhkBcVVcUQeZxu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fPJhkBcVVcUQeZxu .arrowheadPath{fill:#333333;}#mermaid-svg-fPJhkBcVVcUQeZxu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fPJhkBcVVcUQeZxu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fPJhkBcVVcUQeZxu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fPJhkBcVVcUQeZxu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fPJhkBcVVcUQeZxu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fPJhkBcVVcUQeZxu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fPJhkBcVVcUQeZxu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fPJhkBcVVcUQeZxu .cluster text{fill:#333;}#mermaid-svg-fPJhkBcVVcUQeZxu .cluster span{color:#333;}#mermaid-svg-fPJhkBcVVcUQeZxu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fPJhkBcVVcUQeZxu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fPJhkBcVVcUQeZxu rect.text{fill:none;stroke-width:0;}#mermaid-svg-fPJhkBcVVcUQeZxu .icon-shape,#mermaid-svg-fPJhkBcVVcUQeZxu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fPJhkBcVVcUQeZxu .icon-shape p,#mermaid-svg-fPJhkBcVVcUQeZxu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fPJhkBcVVcUQeZxu .icon-shape rect,#mermaid-svg-fPJhkBcVVcUQeZxu .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fPJhkBcVVcUQeZxu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fPJhkBcVVcUQeZxu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fPJhkBcVVcUQeZxu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
否
轮询读到条码
条码为空?
清空 _lastBarcode
与上次相同?
跳过,不入库
写生产记录
写入成功?
更新 _lastBarcode
保留旧值,下次重试

最小实现:变化时才保存

在 DashboardViewModel 或专门的生产追溯服务里,可以写出这样的逻辑:

csharp 复制代码
private string _lastBarcode = "";
private readonly SemaphoreSlim _recordLock = new(1, 1);

private async Task HandleDeviceStateAsync(DeviceStates state)
{
    var barcode = state.Barcode?.Trim() ?? "";

    if (string.IsNullOrWhiteSpace(barcode))
    {
        _lastBarcode = "";
        return;
    }

    if (string.Equals(barcode, _lastBarcode,
                      StringComparison.OrdinalIgnoreCase))
        return;

    _lastBarcode = barcode;
    await SaveNewRecordAsync(barcode, state);
}

这里先 Trim,避免扫码器在末尾带回车或空格时把同一个条码当成不同字符串。比较时是否忽略大小写,要根据业务规则决定。很多工厂条码是大小写敏感的,不能机械地统一转小写。示例使用忽略大小写只是为了说明"同一条码"的判断点,正式项目应以条码规范为准。

保存方法负责组装快照:

csharp 复制代码
private async Task SaveNewRecordAsync(string barcode, DeviceStates state)
{
    await _recordLock.WaitAsync();
    try
    {
        var record = new ProductionRecord
        {
            Time = DateTime.Now,
            BatchNo = barcode,
            Volume = state.CurrentVolume,
            Temperature = state.Temperature,
            CycleTime = state.CurrentCycleTime,
            Operator = CurrentUser.Name
        };

        await DataService.SaveProductionRecordAsync(record);
        LogService.Info($"生产记录已保存:{barcode}");
    }
    finally
    {
        _recordLock.Release();
    }
}

为什么要保存快照,而不是只保存条码?因为设备状态会继续变化。把当前液位、温度、周期时间等字段写入记录后,后续查询看到的是"当时发生了什么",而不是"现在设备是什么状态"。


_lastBarcode 放在哪里

放在 ViewModel 里可以快速完成,但它有一个明显限制:页面重新创建,状态就可能丢失。更稳妥的做法是把去重逻辑放到独立服务中:

csharp 复制代码
public sealed class ProductionTraceService
{
    private string _lastBarcode = "";
    private readonly SemaphoreSlim _gate = new(1, 1);

    public async Task<bool> TryRecordAsync(DeviceStates state,
                                            string operatorName)
    {
        var barcode = state.Barcode?.Trim() ?? "";
        if (barcode.Length == 0)
        {
            _lastBarcode = "";
            return false;
        }

        await _gate.WaitAsync();
        try
        {
            if (barcode == _lastBarcode)
                return false;

            var record = ProductionRecord.From(state, operatorName);
            await DataService.SaveProductionRecordAsync(record);
            _lastBarcode = barcode;
            return true;
        }
        finally
        {
            _gate.Release();
        }
    }
}

这里把 _lastBarcode 的更新放在数据库写入成功之后。这样做的好处是:如果数据库写失败,下一次轮询仍然会再次尝试,而不是因为内存状态已经更新导致这条记录永远丢失。

不过也要看到它的边界:如果数据库写成功、进程在更新 _lastBarcode 前崩溃,重启后可能再次写入。真正需要强一致去重时,不能只靠内存变量,要给数据库增加唯一约束或记录业务键。


用业务键做第二道保险

生产记录可以增加一个业务键,例如"条码 + 开始时间"或设备侧的批次号。SQLite 里把业务键设置为唯一,重复写入时由数据库拒绝。

csharp 复制代码
public class ProductionRecord
{
    [Column(IsIdentity = true, IsPrimary = true)]
    public long Id { get; set; }

    [Column(IsNullable = false, StringLength = 64)]
    public string BatchNo { get; set; } = "";

    public DateTime Time { get; set; }
    public string RecordKey { get; set; } = "";
}

如果一个条码可以重复生产,就不能简单地把 BatchNo 设置成唯一。可以让设备批次号、开始时间窗口或生产流水号参与业务键。关键是先问清楚业务:同一条码重复出现代表重复扫描,还是代表返工?技术方案必须服从这个答案。

数据库唯一约束是最后一道防线,不是替代业务判断。把每一次重复都交给数据库抛异常,会让日志充满"正常重复"的错误,看起来像系统经常失败。


条码不是永远可靠

现场采集还有几种情况需要处理:

条码分段到达

如果条码由多个寄存器组成,某一次轮询可能只读到前半段。不能看到非空字符串就马上入库,需要确认长度、结束符或连续两次读取一致。

csharp 复制代码
private string? _candidateBarcode;
private int _stableCount;

private bool IsStable(string barcode)
{
    if (barcode == _candidateBarcode)
        _stableCount++;
    else
    {
        _candidateBarcode = barcode;
        _stableCount = 1;
    }

    return _stableCount >= 2;
}

条码包含不可见字符

回车、换行和零字节在寄存器转换时很常见。清洗逻辑要集中处理,并保留原始数据的诊断日志,但日志里不要输出完整的敏感生产编码。

通信重连后重复上报

断线重连后设备可能把当前条码再次上报。只要 _lastBarcode 没有被错误清空,就不会重复插入。只有收到明确的"条码离开"状态,才应该把上一次条码置空。


记录失败和 UI 不能互相拖住

轮询线程发现新条码后,不应该同步等待一个慢数据库操作再继续读取设备。可以把入库交给后台任务,但要用队列或锁控制并发,不能每次轮询都 Task.Run 一把:

csharp 复制代码
private readonly Channel<ProductionRecord> _records =
    Channel.CreateUnbounded<ProductionRecord>();

private async Task WriterLoopAsync(CancellationToken token)
{
    await foreach (var record in _records.Reader.ReadAllAsync(token))
    {
        try
        {
            await DataService.SaveProductionRecordAsync(record);
        }
        catch (Exception ex)
        {
            LogService.Error("保存生产记录失败", ex);
        }
    }
}

文章前面的最小实现适合数据量小、链路简单的项目。数据量上来后,单独的写入队列更容易做重试、统计和停机排空。无论采用哪种方案,都要保证"采集线程不被数据库卡死"和"写入失败不会被静默吞掉"。


踩坑记录

只判断非空,不判断变化

这是重复数据的根因。状态持续存在不等于事件持续发生。

先更新 _lastBarcode 再写数据库

写库失败后,下一次不会再尝试,造成静默丢记录。除非使用了可靠队列,否则应在写入成功后更新状态。

把条码清洗写在多个页面

一个页面去空格,另一个页面转大小写,最后同一条码在不同功能里变成不同值。清洗、校验和去重应该集中在追溯服务。

用 UI 集合当数据库

UI 集合适合显示最近记录,不适合承担持久化职责。页面关闭、导航切换或程序重启都会让集合失效。


本篇小结

问题 做法
轮询重复读取怎么办 把状态变化转换成事件
如何避免重复记录 _lastBarcode + 数据库业务键
保存什么数据 条码和当时的设备状态快照
数据库写失败怎么办 不提前更新状态,记录错误并重试
页面重建会丢状态吗 将追溯逻辑放到独立服务
采集和入库如何解耦 小项目用锁,大项目用写入队列

到这里,监控系统已经能留下生产记录。下一步要解决的是:数据不能只躺在数据库里,现场还需要把它交给 Excel、质量系统或客户。


下期预告

第11篇:CSV 流式导出

下一篇实现按时间范围查询生产记录、分批写入 CSV、处理中文编码和大数据量导出,重点是避免一次性把所有数据塞进内存。

相关推荐
czhc11400756635 小时前
退出防呆:用“总闸“收口所有退出入口,以及三个并发语法坑
开发语言·c#
淡海水8 小时前
02-01-原理篇-Mark-Sweep与变种算法
算法·unity·c#·游戏引擎·.net·gc
UIU11411 小时前
同一个变量在两个 .c 文件里类型不一致,程序会怎样?
c++·学习·c#
波力海苔夹心脆6751 天前
C# 值类型与引用类型详解:存储位置、赋值机制、参数传递、相等比较、装箱拆箱与常见陷阱
开发语言·jvm·经验分享·笔记·c#·.net
估值探索者1 天前
【Python量化策略实战 #02】多因子合成mom和vol两因子打分合成
开发语言·c++·python·数据挖掘·c#
warrah1 天前
modbus协议 青鸟
modbus
换元不配限1 天前
Android 架构演进实战:MVC → MVP → MVVM → MVI
android·架构·mvc·mvvm·mvp·mvi
淡海水1 天前
01-04-认知篇-Unity内存全景
unity·c#·游戏引擎·.net·gc
淡海水2 天前
01-03-认知篇-C#内存模型深度解析
unity·c#·游戏引擎·.net·gc