| | |
| | | using System.Linq; |
| | | using MES.Service.DB; |
| | | using MES.Service.Dto.service; |
| | | using MES.Service.Modes; |
| | |
| | | public class SpiAoiService |
| | | { |
| | | /// <summary> |
| | | /// 上传SPI/AOI检测数据 |
| | | /// Upload AOI header data. |
| | | /// </summary> |
| | | /// <param name="request">上传请求DTO</param> |
| | | /// <returns>上传响应DTO</returns> |
| | | public SpiAoiUploadResponse UploadSpiAoiData(SpiAoiUploadRequest request) |
| | | /// <param name="header">Header DTO.</param> |
| | | /// <returns>Header upload response.</returns> |
| | | public SpiAoiHeaderUploadResponse UploadAoiHeader(SpiAoiHeaderDto header) |
| | | { |
| | | // 1. 基本校验 |
| | | ValidateRequest(request); |
| | | if (header == null) |
| | | { |
| | | throw new Exception("header cannot be null"); |
| | | } |
| | | |
| | | try |
| | | { |
| | | SpiAoiUploadResponse response = null; |
| | | |
| | | SqlSugarHelper.UseTransactionWithOracle(db => |
| | | { |
| | | // 2. 检查条码是否已存在 |
| | | var existingHeader = db.Queryable<MesSpiAoiHeader>() |
| | | .Where(x => x.BoardBarcode == request.Header.BoardBarcode) |
| | | .First(); |
| | | |
| | | if (existingHeader != null) |
| | | { |
| | | throw new Exception($"条码 {request.Header.BoardBarcode} 已存在,不允许重复上传"); |
| | | } |
| | | |
| | | // 3. 转换并插入主表数据 |
| | | var header = ConvertHeaderDtoToEntity(request.Header); |
| | | header.CreatedAt = DateTime.Now; |
| | | header.UpdatedAt = DateTime.Now; |
| | | |
| | | var headerId = db.Insertable(header).ExecuteReturnIdentity(); |
| | | |
| | | // 4. 转换并插入子表数据 |
| | | var detailCount = 0; |
| | | if (request.Details != null && request.Details.Count > 0) |
| | | { |
| | | var details = ConvertDetailDtoListToEntity(request.Details, headerId); |
| | | |
| | | // 数据校验(记录警告但不阻断) |
| | | ValidateDetailData(request.Details); |
| | | |
| | | detailCount = db.Insertable(details).ExecuteCommand(); |
| | | } |
| | | |
| | | response = new SpiAoiUploadResponse |
| | | { |
| | | HeaderId = headerId, |
| | | DetailCount = detailCount |
| | | }; |
| | | |
| | | return 1; // 返回受影响的行数供事务判断 |
| | | }); |
| | | |
| | | if (response == null) |
| | | { |
| | | throw new Exception("上传失败"); |
| | | } |
| | | |
| | | return response; |
| | | return UploadAoiHeaderBatchInternal( |
| | | new List<SpiAoiHeaderDto> { header })[0]; |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | throw new Exception($"上传SPI/AOI检测数据失败: {ex.Message}", ex); |
| | | throw new Exception($"Failed to upload AOI header: {ex.Message}", |
| | | ex); |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 根据条码查询SPI/AOI检测数据 |
| | | /// Upload multiple AOI header records within a single transaction. |
| | | /// </summary> |
| | | /// <param name="boardBarcode">条码</param> |
| | | /// <returns>检测数据(主表+子表)</returns> |
| | | public (MesSpiAoiHeader header, List<MesSpiAoiDetail> details) GetByBarcode(string boardBarcode) |
| | | /// <param name="headers">Header DTO collection.</param> |
| | | /// <returns>Header upload responses.</returns> |
| | | public List<SpiAoiHeaderUploadResponse> UploadAoiHeaderBatch( |
| | | List<SpiAoiHeaderDto> headers) |
| | | { |
| | | if (headers == null) |
| | | { |
| | | throw new Exception("headers cannot be null"); |
| | | } |
| | | |
| | | try |
| | | { |
| | | return UploadAoiHeaderBatchInternal(headers); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | throw new Exception( |
| | | $"Failed to upload AOI header batch: {ex.Message}", ex); |
| | | } |
| | | } |
| | | |
| | | private List<SpiAoiHeaderUploadResponse> UploadAoiHeaderBatchInternal( |
| | | List<SpiAoiHeaderDto> headers) |
| | | { |
| | | var headerList = headers.ToList(); |
| | | if (headerList.Count == 0) |
| | | { |
| | | throw new Exception("headers cannot be empty"); |
| | | } |
| | | |
| | | foreach (var header in headerList) |
| | | { |
| | | ValidateHeader(header); |
| | | } |
| | | |
| | | var duplicateBarcodes = headerList |
| | | .GroupBy(h => h.BoardBarcode) |
| | | .Where(g => g.Count() > 1) |
| | | .Select(g => g.Key) |
| | | .ToList(); |
| | | |
| | | if (duplicateBarcodes.Any()) |
| | | { |
| | | throw new Exception( |
| | | $"Duplicate board barcodes in request: {string.Join(", ", |
| | | duplicateBarcodes)}."); |
| | | } |
| | | |
| | | var responses = new List<SpiAoiHeaderUploadResponse>(); |
| | | |
| | | SqlSugarHelper.UseTransactionWithOracle(db => |
| | | { |
| | | var boardBarcodes = headerList.Select(h => h.BoardBarcode) |
| | | .ToList(); |
| | | |
| | | if (boardBarcodes.Any()) |
| | | { |
| | | var existingBarcodes = db.Queryable<MesSpiAoiHeader>() |
| | | .Where(x => boardBarcodes.Contains(x.BoardBarcode)) |
| | | .Select(x => x.BoardBarcode) |
| | | .ToList(); |
| | | |
| | | if (existingBarcodes.Any()) |
| | | { |
| | | throw new Exception( |
| | | $"Board barcodes {string.Join(", ", existingBarcodes)} already exist; duplicate AOI uploads are not allowed."); |
| | | } |
| | | } |
| | | |
| | | foreach (var headerDto in headerList) |
| | | { |
| | | var entity = ConvertHeaderDtoToEntity(headerDto); |
| | | var timestamp = DateTime.Now; |
| | | entity.CreatedAt = timestamp; |
| | | entity.UpdatedAt = timestamp; |
| | | |
| | | var headerId = db.Insertable(entity).ExecuteReturnIdentity(); |
| | | responses.Add(new SpiAoiHeaderUploadResponse |
| | | { |
| | | HeaderId = headerId |
| | | }); |
| | | } |
| | | |
| | | return responses.Count; |
| | | }); |
| | | |
| | | if (responses.Count == 0) |
| | | { |
| | | throw new Exception("AOI upload returned no result."); |
| | | } |
| | | |
| | | return responses; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// Upload SPI detail data. |
| | | /// </summary> |
| | | /// <param name="request">Detail upload DTO.</param> |
| | | /// <returns>Detail upload response.</returns> |
| | | public SpiAoiDetailUploadResponse UploadSpiDetails(SpiAoiDetailDto request) |
| | | { |
| | | if (request == null) |
| | | { |
| | | throw new Exception("request cannot be null"); |
| | | } |
| | | |
| | | try |
| | | { |
| | | return UploadSpiDetailsInternal( |
| | | new List<SpiAoiDetailDto> { request }); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | throw new Exception($"Failed to upload SPI details: {ex.Message}", |
| | | ex); |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// Upload multiple SPI detail records within a single transaction. |
| | | /// </summary> |
| | | /// <param name="requests">Detail DTO collection.</param> |
| | | /// <returns>Aggregated upload response.</returns> |
| | | public SpiAoiDetailUploadResponse UploadSpiDetailsBatch( |
| | | List<SpiAoiDetailDto> requests) |
| | | { |
| | | if (requests == null) |
| | | { |
| | | throw new Exception("details cannot be null"); |
| | | } |
| | | |
| | | try |
| | | { |
| | | return UploadSpiDetailsInternal(requests); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | throw new Exception( |
| | | $"Failed to upload SPI detail batch: {ex.Message}", ex); |
| | | } |
| | | } |
| | | |
| | | private SpiAoiDetailUploadResponse UploadSpiDetailsInternal( |
| | | List<SpiAoiDetailDto> requests) |
| | | { |
| | | var detailList = requests.ToList(); |
| | | if (detailList.Count == 0) |
| | | { |
| | | throw new Exception("details cannot be empty"); |
| | | } |
| | | |
| | | ValidateDetailData(detailList); |
| | | |
| | | var affectedRows = SqlSugarHelper.UseTransactionWithOracle(db => |
| | | { |
| | | var total = 0; |
| | | foreach (var detail in detailList) |
| | | { |
| | | var detailEntity = ConvertDetailDtoToEntity(detail); |
| | | total += db.Insertable(detailEntity).ExecuteCommand(); |
| | | } |
| | | |
| | | return total; |
| | | }); |
| | | |
| | | if (affectedRows <= 0) |
| | | { |
| | | throw new Exception( |
| | | "SPI detail insert returned no affected rows."); |
| | | } |
| | | |
| | | return new SpiAoiDetailUploadResponse |
| | | { |
| | | DetailCount = affectedRows |
| | | }; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// Retrieve SPI/AOI data by board barcode. |
| | | /// </summary> |
| | | /// <param name="boardBarcode">Board barcode.</param> |
| | | /// <returns>Header and detail tuple.</returns> |
| | | public (MesSpiAoiHeader header, List<MesSpiAoiDetail> details) GetByBarcode( |
| | | string boardBarcode) |
| | | { |
| | | try |
| | | { |
| | |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | throw new Exception($"查询SPI/AOI检测数据失败: {ex.Message}", ex); |
| | | throw new Exception($"查询SPI/AOI检测数据失�? {ex.Message}", ex); |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 根据ID查询SPI/AOI检测数据 |
| | | /// </summary> |
| | | /// <param name="headerId">主表ID</param> |
| | | /// <returns>检测数据(主表+子表)</returns> |
| | | public (MesSpiAoiHeader header, List<MesSpiAoiDetail> details) GetById(decimal headerId) |
| | | public (MesSpiAoiHeader header, List<MesSpiAoiDetail> details) GetById( |
| | | decimal headerId) |
| | | { |
| | | try |
| | | { |
| | |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | throw new Exception($"查询SPI/AOI检测数据失败: {ex.Message}", ex); |
| | | throw new Exception($"查询SPI/AOI检测数据失�? {ex.Message}", ex); |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 分页查询SPI/AOI检测数据 |
| | | /// 分页查询SPI/AOI检测数�? |
| | | /// </summary> |
| | | /// <param name="boardBarcode">条码(可选)</param> |
| | | /// <param name="workOrder">工单(可选)</param> |
| | | /// <param name="surface">板面(可选)</param> |
| | | /// <param name="startDate">开始日期(可选)</param> |
| | | /// <param name="startDate">开始日�?可选)</param> |
| | | /// <param name="endDate">结束日期(可选)</param> |
| | | /// <param name="pageIndex">页码</param> |
| | | /// <param name="pageSize">页大小</param> |
| | | /// <param name="pageSize">页大�?/param> |
| | | /// <returns>分页数据</returns> |
| | | public (List<MesSpiAoiHeader> items, int totalCount) GetPage( |
| | | string boardBarcode = null, |
| | |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | throw new Exception($"分页查询SPI/AOI检测数据失败: {ex.Message}", ex); |
| | | throw new Exception($"分页查询SPI/AOI检测数据失�? {ex.Message}", ex); |
| | | } |
| | | } |
| | | |
| | | #region 私有方法 |
| | | |
| | | /// <summary> |
| | | /// 校验请求参数 |
| | | /// Validate AOI header payload. |
| | | /// </summary> |
| | | /// <param name="request">请求DTO</param> |
| | | private void ValidateRequest(SpiAoiUploadRequest request) |
| | | /// <param name="header">Header DTO.</param> |
| | | private void ValidateHeader(SpiAoiHeaderDto header) |
| | | { |
| | | if (request == null) |
| | | if (header == null) |
| | | { |
| | | throw new Exception("请求参数不能为空"); |
| | | throw new Exception("header cannot be null"); |
| | | } |
| | | |
| | | if (request.Header == null) |
| | | if (StringUtil.IsNullOrEmpty(header.TestDate)) |
| | | { |
| | | throw new Exception("header 不能为空"); |
| | | throw new Exception("testDate is required"); |
| | | } |
| | | |
| | | if (request.Details == null || request.Details.Count == 0) |
| | | if (StringUtil.IsNullOrEmpty(header.TestTime)) |
| | | { |
| | | throw new Exception("details 不能为空"); |
| | | throw new Exception("testTime is required"); |
| | | } |
| | | |
| | | // 校验必填字段 |
| | | if (StringUtil.IsNullOrEmpty(request.Header.TestDate)) |
| | | if (StringUtil.IsNullOrEmpty(header.TestResult)) |
| | | { |
| | | throw new Exception("testDate 不能为空"); |
| | | throw new Exception("testResult is required"); |
| | | } |
| | | |
| | | if (StringUtil.IsNullOrEmpty(request.Header.TestTime)) |
| | | if (StringUtil.IsNullOrEmpty(header.BoardBarcode)) |
| | | { |
| | | throw new Exception("testTime 不能为空"); |
| | | throw new Exception("boardBarcode is required"); |
| | | } |
| | | |
| | | if (StringUtil.IsNullOrEmpty(request.Header.TestResult)) |
| | | if (StringUtil.IsNullOrEmpty(header.Surface)) |
| | | { |
| | | throw new Exception("testResult 不能为空"); |
| | | throw new Exception("surface is required"); |
| | | } |
| | | |
| | | if (StringUtil.IsNullOrEmpty(request.Header.BoardBarcode)) |
| | | if (header.Surface != "T" && header.Surface != "B") |
| | | { |
| | | throw new Exception("boardBarcode 不能为空"); |
| | | throw new Exception("surface must be T or B"); |
| | | } |
| | | |
| | | if (StringUtil.IsNullOrEmpty(request.Header.Surface)) |
| | | if (header.BoardBarcode.Length > 128) |
| | | { |
| | | throw new Exception("surface 不能为空"); |
| | | throw new Exception("boardBarcode cannot exceed 128 characters"); |
| | | } |
| | | |
| | | // 校验枚举值 |
| | | if (request.Header.Surface != "T" && request.Header.Surface != "B") |
| | | if (!StringUtil.IsNullOrEmpty(header.TestResult) && |
| | | header.TestResult.Length > 12) |
| | | { |
| | | throw new Exception("surface 必须为 T 或 B"); |
| | | } |
| | | |
| | | // 校验字符串长度 |
| | | if (request.Header.BoardBarcode.Length > 128) |
| | | { |
| | | throw new Exception("boardBarcode 长度不能超过 128 字符"); |
| | | } |
| | | |
| | | if (request.Header.TestResult.Length > 12) |
| | | { |
| | | throw new Exception("testResult 长度不能超过 12 字符"); |
| | | } |
| | | |
| | | // 校验数值非负 |
| | | foreach (var detail in request.Details) |
| | | { |
| | | if (detail.OffsetCount < 0 || detail.MissingCount < 0 || |
| | | detail.ReverseCount < 0 || detail.LiftedCount < 0 || |
| | | detail.FloatHighCount < 0 || detail.TombstoneCount < 0 || |
| | | detail.FlipCount < 0 || detail.WrongPartCount < 0 || |
| | | detail.LeadLiftCount < 0 || detail.ColdJointCount < 0 || |
| | | detail.NoSolderCount < 0 || detail.InsufficientSolderCount < 0 || |
| | | detail.ExcessSolderCount < 0 || detail.BridgeCount < 0 || |
| | | detail.CopperExposureCount < 0 || detail.SpikeCount < 0 || |
| | | detail.ForeignMatterCount < 0 || detail.GlueOverflowCount < 0 || |
| | | detail.PinOffsetCount < 0 || detail.InputBoards < 0 || |
| | | detail.OkBoards < 0 || detail.PassBoards < 0 || |
| | | detail.DefectBoards < 0 || detail.DefectPoints < 0 || |
| | | detail.MeasuredPoints < 0 || detail.PendingPoints < 0) |
| | | { |
| | | throw new Exception("所有计数字段必须 >= 0"); |
| | | } |
| | | throw new Exception("testResult cannot exceed 12 characters"); |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 校验子表数据(记录警告但不阻断) |
| | | /// </summary> |
| | | /// <param name="details">子表DTO列表</param> |
| | | private void ValidateDetailData(List<SpiAoiDetailDto> details) |
| | | { |
| | | foreach (var detail in details) |
| | | { |
| | | // 校验 passBoards <= inputBoards |
| | | if (detail.PassBoards > detail.InputBoards) |
| | | { |
| | | Console.WriteLine($"[警告] passBoards({detail.PassBoards}) 大于 inputBoards({detail.InputBoards})"); |
| | | } |
| | | |
| | | // 校验 defectBoards = inputBoards - passBoards |
| | | var expectedDefectBoards = detail.InputBoards - detail.PassBoards; |
| | | if (Math.Abs(detail.DefectBoards - expectedDefectBoards) > 0) |
| | | { |
| | | Console.WriteLine($"[警告] defectBoards({detail.DefectBoards}) 与计算值({expectedDefectBoards})不一致"); |
| | | } |
| | | |
| | | // 校验 passRate 偏差在 ±1.0 以内 |
| | | if (detail.InputBoards > 0 && detail.PassRate.HasValue) |
| | | { |
| | | var expectedPassRate = (decimal)detail.PassBoards / detail.InputBoards * 100; |
| | | var deviation = Math.Abs(detail.PassRate.Value - expectedPassRate); |
| | | if (deviation > 1.0m) |
| | | { |
| | | Console.WriteLine($"[警告] passRate({detail.PassRate}) 与计算值({expectedPassRate:F2})偏差超过1.0"); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 将主表DTO转换为实体 |
| | | /// </summary> |
| | | /// <param name="dto">主表DTO</param> |
| | | /// <returns>主表实体</returns> |
| | | |
| | | private MesSpiAoiHeader ConvertHeaderDtoToEntity(SpiAoiHeaderDto dto) |
| | | { |
| | | return new MesSpiAoiHeader |
| | |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 将子表DTO列表转换为实体列表 |
| | | /// Perform non-blocking SPI detail checks (warnings only). |
| | | /// </summary> |
| | | /// <param name="dtoList">子表DTO列表</param> |
| | | /// <param name="headerId">主表ID</param> |
| | | /// <returns>子表实体列表</returns> |
| | | private List<MesSpiAoiDetail> ConvertDetailDtoListToEntity( |
| | | List<SpiAoiDetailDto> dtoList, decimal headerId) |
| | | /// <param name="details">Detail DTO list.</param> |
| | | private void ValidateDetailData(List<SpiAoiDetailDto> details) |
| | | { |
| | | foreach (var detail in details) |
| | | { |
| | | // Validate passBoards <= inputBoards |
| | | if (detail.PassBoards > detail.InputBoards) |
| | | { |
| | | Console.WriteLine( |
| | | $"[Warning] passBoards({detail.PassBoards}) is greater than inputBoards({detail.InputBoards})."); |
| | | } |
| | | |
| | | // Validate defectBoards = inputBoards - passBoards |
| | | var expectedDefectBoards = detail.InputBoards - detail.PassBoards; |
| | | if (Math.Abs(detail.DefectBoards - expectedDefectBoards) > 0) |
| | | { |
| | | Console.WriteLine( |
| | | $"[Warning] defectBoards({detail.DefectBoards}) does not match the expected value ({expectedDefectBoards})."); |
| | | } |
| | | |
| | | // Validate passRate deviation stays within +/- 1.0 |
| | | if (detail.InputBoards > 0 && detail.PassRate.HasValue) |
| | | { |
| | | var expectedPassRate = (decimal)detail.PassBoards / |
| | | detail.InputBoards * 100; |
| | | var deviation = |
| | | Math.Abs(detail.PassRate.Value - expectedPassRate); |
| | | if (deviation > 1.0m) |
| | | { |
| | | Console.WriteLine( |
| | | $"[Warning] passRate({detail.PassRate}) deviates from the expected value ({expectedPassRate:F2}) by more than 1.0."); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private MesSpiAoiDetail ConvertDetailDtoToEntity(SpiAoiDetailDto dto) |
| | | { |
| | | var now = DateTime.Now; |
| | | return dtoList.Select(dto => new MesSpiAoiDetail |
| | | return new MesSpiAoiDetail |
| | | { |
| | | HeaderId = headerId, |
| | | OffsetCount = dto.OffsetCount, |
| | | MissingCount = dto.MissingCount, |
| | | ReverseCount = dto.ReverseCount, |
| | | LiftedCount = dto.LiftedCount, |
| | | FloatHighCount = dto.FloatHighCount, |
| | | TombstoneCount = dto.TombstoneCount, |
| | | FlipCount = dto.FlipCount, |
| | | WrongPartCount = dto.WrongPartCount, |
| | | LeadLiftCount = dto.LeadLiftCount, |
| | | ColdJointCount = dto.ColdJointCount, |
| | | NoSolderCount = dto.NoSolderCount, |
| | | InsufficientSolderCount = dto.InsufficientSolderCount, |
| | | ExcessSolderCount = dto.ExcessSolderCount, |
| | | BridgeCount = dto.BridgeCount, |
| | | CopperExposureCount = dto.CopperExposureCount, |
| | | SpikeCount = dto.SpikeCount, |
| | | ForeignMatterCount = dto.ForeignMatterCount, |
| | | GlueOverflowCount = dto.GlueOverflowCount, |
| | | PinOffsetCount = dto.PinOffsetCount, |
| | | HeaderId = dto.HeaderId ?? 0, |
| | | AreaOverflowCount = dto.AreaOverflowCount, |
| | | AreaUnderflowCount = dto.AreaUnderflowCount, |
| | | ExceedingHeightCount = dto.ExceedingHeightCount, |
| | | InsufficientHeightCount = dto.InsufficientHeightCount, |
| | | XDeviationCount = dto.XDeviationCount, |
| | | YDeviationCount = dto.YDeviationCount, |
| | | CollapseCount = dto.CollapseCount, |
| | | SolderPullTipCount = dto.SolderPullTipCount, |
| | | AbnormalityCount = dto.AbnormalityCount, |
| | | LineDisplayName = dto.LineDisplayName, |
| | | MachineName = dto.MachineName, |
| | | InputBoards = dto.InputBoards, |
| | |
| | | PendingPoints = dto.PendingPoints, |
| | | CreatedAt = now, |
| | | UpdatedAt = now |
| | | }).ToList(); |
| | | }; |
| | | } |
| | | |
| | | #endregion |