tjx
2025-10-15 9057d0f6f3a46b93d62d0b71c7f4f03eca41f3a9
MES.Service/service/QC/SpiAoiService.cs
@@ -1,3 +1,4 @@
using System.Linq;
using MES.Service.DB;
using MES.Service.Dto.service;
using MES.Service.Modes;
@@ -11,78 +12,216 @@
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
        {
@@ -105,16 +244,12 @@
        }
        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
        {
@@ -137,20 +272,20 @@
        }
        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,
@@ -184,137 +319,66 @@
        }
        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
@@ -335,18 +399,50 @@
    }
    /// <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,
            HeaderId = dto.HeaderId ?? 0,
            OffsetCount = dto.OffsetCount,
            MissingCount = dto.MissingCount,
            ReverseCount = dto.ReverseCount,
@@ -380,7 +476,7 @@
            PendingPoints = dto.PendingPoints,
            CreatedAt = now,
            UpdatedAt = now
        }).ToList();
        };
    }
    #endregion