diff --git a/MDM/Models/Process/ProcessOperation.cs b/MDM/Models/Process/ProcessOperation.cs index bf3778f..b13d14e 100644 --- a/MDM/Models/Process/ProcessOperation.cs +++ b/MDM/Models/Process/ProcessOperation.cs @@ -37,6 +37,13 @@ namespace MDM.Model.Process [SugarColumn(ColumnName = "operation_seq", Length = 50)] public int? OperationSeq { get; set; } + + /// + /// 上一站工序序号(工序流转号) + /// + [SugarColumn(ColumnName = "last_operation_seq", Length = 50)] + public int? LastOperationSeq { get; set; } + /// /// 工序类别 /// diff --git a/RIZO.Model/MES/product_trace/ProductLifecycle.cs b/RIZO.Model/MES/product_trace/ProductLifecycle.cs new file mode 100644 index 0000000..66eb260 --- /dev/null +++ b/RIZO.Model/MES/product_trace/ProductLifecycle.cs @@ -0,0 +1,92 @@ +namespace RIZO.Model.MES.product_trace +{ + /// + /// 产品生命周期表 + /// + [SugarTable("product_lifecycle")] + public class ProductLifecycle + { + /// + /// 主键ID + /// + [SugarColumn(ColumnName = "Id", IsPrimaryKey = true, IsIdentity = true)] + public int Id { get; set; } + + /// + /// 工单号 + /// + [SugarColumn(ColumnName = "workorder", Length = 50)] + [StringLength(50)] + public string Workorder { get; set; } + + /// + /// 当前状态(0 未生产,1 生产中,2 完工中,3 返工中,4报废中) + /// + [SugarColumn(ColumnName = "product_current_status")] + public int? ProductCurrentStatus { get; set; } + + /// + /// 产品SN + /// + [SugarColumn(ColumnName = "product_SN", Length = 50)] + [StringLength(50)] + public string ProductSN { get; set; } + + /// + /// 线别 + /// + [SugarColumn(ColumnName = "line_code", Length = 50)] + [StringLength(50)] + public string LineCode { get; set; } + + /// + /// 组别 + /// + [SugarColumn(ColumnName = "group_code", Length = 50)] + [StringLength(50)] + public string GroupCode { get; set; } + + /// + /// 工艺路线 + /// + [SugarColumn(ColumnName = "routing_code", Length = 50)] + [StringLength(50)] + public string RoutingCode { get; set; } + + /// + /// 产品开工时间 + /// + [SugarColumn(ColumnName = "product_start_time")] + public DateTime? ProductStartTime { get; set; } + + /// + /// 产品返工时间 + /// + [SugarColumn(ColumnName = "product_rework_time")] + public DateTime? ProductReworkTime { get; set; } + + /// + /// 产品完工时间 + /// + [SugarColumn(ColumnName = "product_finish_time")] + public DateTime? ProductFinishTime { get; set; } + + /// + /// 产品报废时间 + /// + [SugarColumn(ColumnName = "product_scrap_time")] + public DateTime? ProductScrapTime { get; set; } + + /// + /// 创建时间 + /// + [SugarColumn(ColumnName = "created_time")] + public DateTime? CreatedTime { get; set; } + + /// + /// 更新时间 + /// + [SugarColumn(ColumnName = "updated_time")] + public DateTime? UpdatedTime { get; set; } + } +} \ No newline at end of file diff --git a/RIZO.Model/MES/product_trace/ProductPassStationRecord.cs b/RIZO.Model/MES/product_trace/ProductPassStationRecord.cs new file mode 100644 index 0000000..02887e8 --- /dev/null +++ b/RIZO.Model/MES/product_trace/ProductPassStationRecord.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RIZO.Model.MES.product_trace +{ + /// + /// 过站记录表 + /// + [SugarTable("product_passstation_record")] + public class ProductPassStationRecord + { + /// + /// 主键ID + /// + [SugarColumn(ColumnName = "Id", IsPrimaryKey = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 工单号 + /// + [SugarColumn(ColumnName = "workorder", Length = 50)] + [StringLength(50)] + public string Workorder { get; set; } + + /// + /// 产品SN + /// + [SugarColumn(ColumnName = "product_SN", Length = 100)] + [StringLength(100)] + public string ProductSN { get; set; } + + /// + /// 工站code + /// + [SugarColumn(ColumnName = "workstationCode", Length = 50)] + [StringLength(50)] + public string WorkstationCode { get; set; } + + /// + /// 工艺路线code + /// + [SugarColumn(ColumnName = "routingcode", Length = 50)] + [StringLength(50)] + public string Routingcode { get; set; } + + /// + /// 工序code + /// + [SugarColumn(ColumnName = "operationCode", Length = 50)] + [StringLength(50)] + public string OperationCode { get; set; } + + /// + /// 当前产品处于的生命周期 + /// + [SugarColumn(ColumnName = "production_life_stage")] + public int? ProductionLifeStage { get; set; } + + /// + /// 过站类型(0 入站请求,1入站完毕,2出站请求,3出站完毕) + /// + [SugarColumn(ColumnName = "passtation_type")] + public int? PasstationType { get; set; } + + /// + /// 过站描述 + /// + [SugarColumn(ColumnName = "passtation_description", Length = 255)] + [StringLength(255)] + public string PasstationDescription { get; set; } + + /// + /// 生效时间 + /// + [SugarColumn(ColumnName = "effect_time")] + public DateTime? EffectTime { get; set; } + + /// + /// 进站时间 + /// + [SugarColumn(ColumnName = "InStationTime")] + public DateTime? InStationTime { get; set; } + + /// + /// 出站时间 + /// + [SugarColumn(ColumnName = "OutStationTime")] + public DateTime? OutStationTime { get; set; } + + /// + /// 加工时间(s) + /// + [SugarColumn(ColumnName = "DurationSeconds")] + public int? DurationSeconds { get; set; } + + /// + /// 操作人 + /// + [SugarColumn(ColumnName = "operator", Length = 50)] + [StringLength(50)] + public string Operator { get; set; } + + /// + /// 结果代码1 是OK 其他NG + /// + [SugarColumn(ColumnName = "result_code")] + public int? ResultCode { get; set; } + + /// + /// 结果描述 + /// + [SugarColumn(ColumnName = "result_description", Length = 255)] + [StringLength(255)] + public string ResultDescription { get; set; } + + /// + /// 创建时间 + /// + [SugarColumn(ColumnName = "created_time")] + public DateTime? CreatedTime { get; set; } + + /// + /// 更新时间 + /// + [SugarColumn(ColumnName = "updated_time")] + public DateTime? UpdatedTime { get; set; } + } +} diff --git a/RIZO.Service/PLCBackground/PlcConntectHepler.cs b/RIZO.Service/PLCBackground/PlcConntectHepler.cs new file mode 100644 index 0000000..8da255a --- /dev/null +++ b/RIZO.Service/PLCBackground/PlcConntectHepler.cs @@ -0,0 +1,355 @@ +using S7.Net; // 确保引用 S7.Net 库 +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace RIZO.Service.PLC +{ + + + + public class PlcConntectHepler : IDisposable + { + private Plc _plc; + private readonly string _ipAddress; + private readonly CpuType _cpuType; + private readonly short _rack; + private readonly short _slot; + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + private bool _disposed; + + public PlcConntectHepler(string ipAddress, CpuType cpuType = CpuType.S71200, short rack = 0, short slot = 1) + { + _ipAddress = ipAddress; + _cpuType = cpuType; + _rack = rack; + _slot = slot; + } + + private async Task ConnectAsync(CancellationToken cancellationToken = default) + { + int maxRetries = 3; + int retryDelayMs = 2000; + int attempt = 0; + + while (attempt < maxRetries) + { + attempt++; + try + { + _plc = new Plc(_cpuType, _ipAddress, _rack, _slot); + await Task.Run(() => _plc.Open(), cancellationToken); // 在线程池中执行同步Open + Console.WriteLine($"成功连接到PLC: {_ipAddress} (尝试{attempt}/{maxRetries})"); + return; + } + catch (Exception ex) + { + Console.WriteLine($"连接尝试 {attempt}/{maxRetries} 失败: {ex.Message}"); + + if (attempt < maxRetries) + { + Console.WriteLine($"{retryDelayMs / 1000}秒后重试..."); + await Task.Delay(retryDelayMs, cancellationToken); + } + else + { + Console.WriteLine("无法建立PLC连接,请检查网络和设备状态"); + _plc?.Close(); + _plc = null; + } + } + } + } + /// + /// 读取指定类型数据 + /// + /// + /// + /// + /// + + public async Task ReadAsync(string address, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_plc == null || !_plc.IsConnected) + { + await ConnectAsync(cancellationToken); + if (_plc == null || !_plc.IsConnected) + throw new InvalidOperationException("PLC未连接"); + } + + var result = await Task.Run(() => _plc.Read(address), cancellationToken); + + // 根据泛型类型转换结果 + return ConvertResult(result); + } + catch (Exception ex) + { + Console.WriteLine($"Read error: {ex.Message}"); + return default(T); + } + finally + { + _semaphore.Release(); + } + } + + + /// + /// 写入指定类型数据 + /// + /// + /// + /// + /// + public async Task WriteAsync(string address, object value, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_plc == null || !_plc.IsConnected) + { + await ConnectAsync(cancellationToken); + if (_plc == null || !_plc.IsConnected) + throw new InvalidOperationException("PLC未连接"); + } + + await Task.Run(() => _plc.Write(address, value), cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine($"Write error: {ex.Message}"); + } + finally + { + _semaphore.Release(); + } + } + + + + private T ConvertResult(object result) + { + if (result == null) return default(T); + + if (typeof(T).IsAssignableFrom(result.GetType())) + { + return (T)result; + } + + return typeof(T) switch + { + Type t when t == typeof(bool) => (T)(object)Convert.ToBoolean(result), + Type t when t == typeof(short) => (T)(object)Convert.ToInt16(result), + Type t when t == typeof(int) => (T)(object)Convert.ToInt32(result), + Type t when t == typeof(float) => (T)(object)Convert.ToSingle(result), + Type t when t == typeof(double) => (T)(object)Convert.ToDouble(result), + Type t when t == typeof(string) => (T)(object)result.ToString(), + _ => throw new InvalidCastException($"无法将类型 {result.GetType()} 转换为 {typeof(T)}") + }; + } + + /// + /// 从PLC读取字符串(主要方法) + /// + /// 地址字符串(如:"DB1001.DBB1000") + /// 读取到的字符串 + public async Task ReadStringAsync(string address) + { + return await ReadStringAsync(address, CancellationToken.None); + } + + /// + /// 从PLC读取字符串(带取消令牌) + /// + /// 地址字符串(如:"DB1001.DBB1000") + /// 取消令牌 + /// 读取到的字符串 + public async Task ReadStringAsync(string address, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_plc == null || !_plc.IsConnected) + { + await ConnectAsync(cancellationToken); + if (_plc == null || !_plc.IsConnected) + throw new InvalidOperationException("PLC未连接"); + } + + // 解析地址 + if (!ParseAddress(address, out int dbNumber, out int startByte)) + { + throw new ArgumentException($"无效的地址格式: {address}"); + } + + // 读取第一个字节(字符串长度) + byte stringLength = await Task.Run(() => + (byte)_plc.Read(DataType.DataBlock, dbNumber, startByte, VarType.Byte, 1), cancellationToken); + + // 如果长度为0,直接返回空字符串 + if (stringLength == 0) + return string.Empty; + + // 读取字符串内容(从startByte+1开始,读取stringLength个字节) + byte[] stringBytes = await Task.Run(() => + (byte[])_plc.Read(DataType.DataBlock, dbNumber, startByte + 1, VarType.Byte, (int)stringLength), cancellationToken); + + // 将字节数组转换为ASCII字符串 + return Encoding.ASCII.GetString(stringBytes); + } + catch (Exception ex) + { + Console.WriteLine($"ReadString error: {ex.Message}"); + return string.Empty; + } + finally + { + _semaphore.Release(); + } + } + + /// + /// 向PLC写入字符串 + /// + /// 地址字符串(如:"DB1001.DBB1000") + /// 要写入的字符串 + /// 是否写入成功 + public async Task WriteStringAsync(string address, string value) + { + return await WriteStringAsync(address, value, CancellationToken.None); + } + + /// + /// 向PLC写入字符串(带取消令牌) + /// + /// 地址字符串(如:"DB1001.DBB1000") + /// 要写入的字符串 + /// 取消令牌 + /// 是否写入成功 + public async Task WriteStringAsync(string address, string value, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_plc == null || !_plc.IsConnected) + { + await ConnectAsync(cancellationToken); + if (_plc == null || !_plc.IsConnected) + throw new InvalidOperationException("PLC未连接"); + } + + if (value == null) + value = string.Empty; + + // 限制字符串长度(S7字符串最大254字符) + if (value.Length > 254) + { + value = value.Substring(0, 254); + } + + // 准备写入的数据 + List writeData = new List(); + + // 第一个字节:字符串长度 + writeData.Add((byte)value.Length); + + // 后续字节:字符串内容(ASCII编码) + byte[] contentBytes = Encoding.ASCII.GetBytes(value); + writeData.AddRange(contentBytes); + + // 解析地址 + if (!ParseAddress(address, out int dbNumber, out int startByte)) + { + throw new ArgumentException($"无效的地址格式: {address}"); + } + + // 写入数据 + await Task.Run(() => + _plc.WriteBytes(DataType.DataBlock, dbNumber, startByte, writeData.ToArray()), cancellationToken); + + return true; + } + catch (Exception ex) + { + Console.WriteLine($"WriteString error: {ex.Message}"); + return false; + } + finally + { + _semaphore.Release(); + } + } + + /// + /// 解析PLC地址字符串,提取DB号和起始字节 + /// 支持格式:DB1001.DBB1000, DB1001.DBW1000, DB1001.DBD1000 等 + /// + private bool ParseAddress(string address, out int dbNumber, out int startByte) + { + dbNumber = 0; + startByte = 0; + + try + { + // 移除空格并转为大写 + address = address.Replace(" ", "").ToUpper(); + + // 分割DB部分和数据部分 + string[] parts = address.Split('.'); + if (parts.Length < 2) return false; + + // 解析DB号 + string dbPart = parts[0]; + if (!dbPart.StartsWith("DB") || !int.TryParse(dbPart.Substring(2), out dbNumber)) + return false; + + // 解析起始字节 + string dataPart = parts[1]; + + // 提取偏移量数字(去掉DBB/DBW/DBD前缀) + string offsetStr = ""; + if (dataPart.StartsWith("DBB")) + offsetStr = dataPart.Substring(3); + else if (dataPart.StartsWith("DBW")) + offsetStr = dataPart.Substring(3); + else if (dataPart.StartsWith("DBD")) + offsetStr = dataPart.Substring(3); + else + return false; + + if (!int.TryParse(offsetStr, out startByte)) + return false; + + return true; + } + catch + { + return false; + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + + if (disposing) + { + _semaphore.Dispose(); + _plc?.Close(); + (_plc as IDisposable)?.Dispose(); + } + _disposed = true; + } + } + +} diff --git a/RIZO.Service/PLCBackground/Stations/PlcPollingService_OP07_01.cs b/RIZO.Service/PLCBackground/Stations/PlcPollingService_OP07_01.cs new file mode 100644 index 0000000..f76c538 --- /dev/null +++ b/RIZO.Service/PLCBackground/Stations/PlcPollingService_OP07_01.cs @@ -0,0 +1,189 @@ +using MDM.Model.Plant; +using MDM.Model.Process; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NLog; +using RIZO.Model.Mes; +using RIZO.Model.MES.product_trace; +using RIZO.Repository; +using RIZO.Service.MES.product_trace; +using RIZO.Service.PLC; +using S7.Net; +using SqlSugar.IOC; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RIZO.Service.PLCBackground.Stations +{ + + + public class PlcPollingService_OP07_01 : BackgroundService + { + private static Logger _logger = LogManager.GetCurrentClassLogger(); + private PlcConntectHepler _plcService; + private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(5); + private readonly string _ipAddress = "192.168.10.222"; + private readonly CpuType _cpuType = CpuType.S71500; + private string WorkstationCode; + + + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.Info("PLC Polling Service started"); + + // 使用工厂方法创建服务实例 + using (_plcService = new PlcConntectHepler(_ipAddress, _cpuType)) + { + + // 获取当前的工序/工站 + WorkstationCode = await DbScoped.SugarScope.CopyNew().Queryable().Where(it => it.PlcIP == _ipAddress) + .Select(it => it.WorkstationCode).FirstAsync(); + + + while (!stoppingToken.IsCancellationRequested) + { + try + { + //心跳检测 + await _plcService.WriteAsync("DB1010.DBW0", 1); + + // 轮询进站请求信号/工位开始查询请求 + int intoStationAsk = await _plcService.ReadAsync("DB1001.DBW2000"); + if (intoStationAsk == 1) + { + // 处理进站请求 + _logger.Info("Processing into station request..."); + + //获取产品SN码 + string productModel = await _plcService.ReadStringAsync("DB1001.DBB1000"); + string productSN = await _plcService.ReadStringAsync("DB1001.DBB1054"); + + + + + //判断该产品是否允许进站 + + EntryPermissionResult result = await checkEntryPermission(productModel, productSN, WorkstationCode); + await _plcService.WriteAsync("DB1010.DBW2000", (int)result); + + + } + await Task.Delay(_pollingInterval, stoppingToken); + + } + catch (OperationCanceledException) + { + _logger.Info("PLC Polling Service is stopping"); + break; + } + catch (Exception ex) + { + _logger.Error(ex, "PLC polling error"); + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + } + } + } + + _logger.Info("PLC Polling Service stopped"); + } + + /// + /// 校验是否可以进站 + /// + /// 产品型号 + /// 产品SN + /// + private async Task checkEntryPermission(string productModel, string productSN, string workstationCode) + { + // 获取工单 + + //获取工艺路线工序 + List processOperations = await DbScoped.SugarScope.CopyNew().Queryable().LeftJoin((r, o) => r.RoutingCode == o.FkRoutingCode) + .Where((r, o) => r.FkProductMaterialCode == productModel && r.Status == 1) + .Select((r, o) => o).ToListAsync(); + + + + //2上工位无记录 + + //ProcessOperation LastOperation = processOperations.Where(operation => operation.OperationSeq < processOperations.Where(it => it.OperationCode == workstationCode).Select(it => it.OperationSeq).First()).OrderByDescending(operation => operation.OperationSeq).First(); + int LastOperationSeq=processOperations.Where(operation => operation.OperationCode == workstationCode).Select(operation => operation.LastOperationSeq??-1).First(); + ProcessOperation LastOperation = processOperations.Where(it => it.OperationSeq == LastOperationSeq).First(); + bool isExistLastOperationRecord = await DbScoped.SugarScope.CopyNew().Queryable() + .Where(it => it.ProductSN == productSN) + .Where(it => it.OperationCode == LastOperation.OperationCode) + .AnyAsync(); + if (!isExistLastOperationRecord) + { + return EntryPermissionResult.NoRecordAtPreviousStation; + } + + // 3 上工位NG 入站或者出站结果 NG + bool isExistLastOperationNG = await DbScoped.SugarScope.CopyNew().Queryable() + .Where(it => it.ProductSN == productSN) + .Where(it => it.OperationCode == LastOperation.OperationCode) + .Where(it => it.PasstationType == 2 || it.PasstationType == 4) + .Where(it => it.ResultCode != 1) + .AnyAsync(); + if (!isExistLastOperationNG) + { + return EntryPermissionResult.PreviousStationNG; + } + + + // 4 扫码产品型号错误 + bool isExistproductSN = await DbScoped.SugarScope.CopyNew().Queryable() + .Where(it => it.ProductSN == productSN).AnyAsync(); + bool isExistproductSNProducting = await DbScoped.SugarScope.CopyNew().Queryable() + .Where(it => it.ProductSN == productSN && (it.ProductCurrentStatus != 1 && it.ProductCurrentStatus != 3)).AnyAsync(); + if (!isExistproductSN || !isExistproductSNProducting) + { + return EntryPermissionResult.ProductModelError; + } + //6时间超出规定无法生产 + + //查询上一站标准时间 + + + + + + + + + return EntryPermissionResult.UnkownException; + } + + + + + } + enum EntryPermissionResult + { + //1之前工位数据正常 + AllowIntoStation = 1, + // 2上工位无记录 + NoRecordAtPreviousStation = 2, + // 3上工位NG + PreviousStationNG = 3, + // 4扫码产品型号错误 + ProductModelError = 4, + //5本工位NG + CurrentStationNG = 5, + //6时间超出规定无法生产 + TimeExceeded = 6, + //7固化时间未到规定时间 + CuringTimeNotReached = 7, + // 8未找到时间属性 + TimeAttributeNotFound = 8, + // 10条码为空 + BarcodeEmpty = 10, + //0之前工位数据异常 + PreviousStationDataAbnormal = 0, + //11 未知异常 + UnkownException = 11 + } +} + diff --git a/RIZO.Service/RIZO.Service.csproj b/RIZO.Service/RIZO.Service.csproj index 41a52b3..9cf4869 100644 --- a/RIZO.Service/RIZO.Service.csproj +++ b/RIZO.Service/RIZO.Service.csproj @@ -9,6 +9,7 @@ 1591 +