package com.ruoyi.unattended.service.impl; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.ruoyi.unattended.mapper.UnattendedOperationMapper; import com.ruoyi.unattended.pojo.UnattendedOperation; import com.ruoyi.unattended.pojo.vo.UnattendedOperationVo; import com.ruoyi.unattended.service.UnattendedOperationService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; @Slf4j @Service @AllArgsConstructor public class UnattendedOperationServiceImpl extends ServiceImpl implements UnattendedOperationService { /** 红外偏移阈值(%),超过判定为车辆未完全上磅 */ private static final BigDecimal OFFSET_LIMIT = new BigDecimal("5"); private final UnattendedOperationMapper unattendedOperationMapper; /** * 每日过磅统计:按天聚合,货量取净重之和,偏差率取偏移超限车次占比。 */ @Override public List weighbridgeDailyReport(LocalDateTime startDate, LocalDateTime endDate) { List ops = listBetween(startDate, endDate); Map> byDay = ops.stream() .filter(o -> o.getWeightTime() != null) .collect(Collectors.groupingBy(o -> o.getWeightTime().toLocalDate(), TreeMap::new, Collectors.toList())); List result = new ArrayList<>(); for (Map.Entry> entry : byDay.entrySet()) { List day = entry.getValue(); UnattendedOperationVo vo = new UnattendedOperationVo(); vo.setDate(entry.getKey()); vo.setTripCount((long) day.size()); BigDecimal cargoVolume = day.stream() .map(this::netWeightOf) .reduce(BigDecimal.ZERO, BigDecimal::add); vo.setCargoVolume(scale(cargoVolume)); // 偏差率 = 偏移超限车次 / 总车次 * 100 long abnormal = day.stream().filter(this::isAbnormal).count(); BigDecimal rate = day.isEmpty() ? BigDecimal.ZERO : BigDecimal.valueOf(abnormal) .multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(day.size()), 2, RoundingMode.HALF_UP); vo.setDeviationRate(rate); // 附加统计(VO 新增字段) vo.setAbnormalCount(abnormal); vo.setCheatCount(day.stream().filter(o -> o.getCheatFlag() != null && o.getCheatFlag() == 1).count()); result.add(vo); } return result; } @Override public List> carrierPerformance(LocalDateTime startDate, LocalDateTime endDate) { List ops = listBetween(startDate, endDate); Map> byCarrier = ops.stream() .collect(Collectors.groupingBy( o -> StringUtils.hasText(o.getCarrierName()) ? o.getCarrierName() : "未指定承运商")); List> result = new ArrayList<>(); for (Map.Entry> entry : byCarrier.entrySet()) { List list = entry.getValue(); long total = list.size(); long abnormal = list.stream().filter(this::isAbnormal).count(); long cheat = list.stream().filter(o -> o.getCheatFlag() != null && o.getCheatFlag() == 1).count(); // 准时率:无异常且红外状态正常的车次占比 long onTime = total - abnormal; BigDecimal onTimeRate = total == 0 ? BigDecimal.ZERO : BigDecimal.valueOf(onTime).multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); BigDecimal cargo = scale(list.stream().map(this::netWeightOf).reduce(BigDecimal.ZERO, BigDecimal::add)); BigDecimal cheatRate = total == 0 ? BigDecimal.ZERO : BigDecimal.valueOf(cheat).multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); Map row = new LinkedHashMap<>(); row.put("carrierName", entry.getKey()); row.put("tripCount", total); row.put("cargoVolume", cargo); row.put("onTimeCount", onTime); row.put("onTimeRate", onTimeRate); row.put("cheatCount", cheat); row.put("cheatRate", cheatRate); // 绩效评分:准时率占 70%,作弊率反向占 30% BigDecimal score = onTimeRate.multiply(new BigDecimal("0.7")) .add(new BigDecimal("100").subtract(cheatRate).multiply(new BigDecimal("0.3"))) .setScale(2, RoundingMode.HALF_UP); row.put("score", score); result.add(row); } result.sort((a, b) -> ((BigDecimal) b.get("score")).compareTo((BigDecimal) a.get("score"))); return result; } /** * 自动称重流程: * 1. 校验皮重/毛重,计算净重 * 2. 红外偏移防作弊判定(偏移率超阈值则报警并标记作弊) * 3. 匹配采购/销售订单(按车牌+承运商在有效台账中匹配) * 4. 生成磅单号与二维码内容 */ @Override public UnattendedOperation autoWeigh(UnattendedOperation op) { if (op.getTareWeight() == null || op.getGrossWeight() == null) { throw new IllegalArgumentException("皮重和毛重不能为空"); } if (op.getGrossWeight().compareTo(op.getTareWeight()) <= 0) { throw new IllegalArgumentException("毛重必须大于皮重"); } if (op.getWeightTime() == null) { op.setWeightTime(LocalDateTime.now()); } // 1. 净重 op.setNetWeight(scale(op.getGrossWeight().subtract(op.getTareWeight()))); // 2. 红外防作弊:positionOffset 表示车辆位置偏移百分比 BigDecimal offset = op.getPositionOffset() == null ? BigDecimal.ZERO : op.getPositionOffset(); if (offset.compareTo(OFFSET_LIMIT) > 0) { op.setInfraredStatus("alarm"); op.setCheatFlag(1); op.setCheatReason("车辆位置偏移 " + offset + "%,超过阈值的 " + OFFSET_LIMIT + "%,疑似未完全上磅"); op.setWeighStatus("abnormal"); } else { op.setInfraredStatus("normal"); op.setCheatFlag(0); op.setCheatReason(null); op.setWeighStatus("done"); } // 3. 订单匹配:按货物名/承运商匹配采购或销售台账 matchOrder(op); // 4. 磅单号 + 二维码 if (!StringUtils.hasText(op.getWeighNo())) { op.setWeighNo(generateWeighNo(op.getWeightTime())); } op.setQrCode(buildQrContent(op)); // 5. ERP 默认待推送 if (!StringUtils.hasText(op.getErpPushStatus())) { op.setErpPushStatus("pending"); } return op; } @Override public Map pushErp(Long id) { UnattendedOperation op = getById(id); if (op == null) { throw new IllegalArgumentException("地磅记录不存在"); } Map result = new LinkedHashMap<>(); // 实际项目此处调用 ERP 接口;当前以本地状态流转保证业务闭环 boolean success = true; UnattendedOperation update = new UnattendedOperation(); update.setId(id); update.setErpPushStatus(success ? "success" : "failed"); update.setErpPushTime(LocalDateTime.now()); updateById(update); result.put("id", id); result.put("weighNo", op.getWeighNo()); result.put("erpPushStatus", update.getErpPushStatus()); result.put("erpPushTime", update.getErpPushTime()); result.put("message", success ? "已推送至 ERP" : "ERP 推送失败"); log.info("[ERP推送] weighNo={}, status={}", op.getWeighNo(), update.getErpPushStatus()); return result; } /* ------------------------------ 私有方法 ------------------------------ */ private List listBetween(LocalDateTime start, LocalDateTime end) { return unattendedOperationMapper.selectList( Wrappers.lambdaQuery() .between(UnattendedOperation::getWeightTime, start, end) .orderByAsc(UnattendedOperation::getWeightTime)); } private BigDecimal netWeightOf(UnattendedOperation op) { if (op.getNetWeight() != null) { return op.getNetWeight(); } if (op.getGrossWeight() != null && op.getTareWeight() != null) { return op.getGrossWeight().subtract(op.getTareWeight()); } return BigDecimal.ZERO; } private boolean isAbnormal(UnattendedOperation op) { if (op.getCheatFlag() != null && op.getCheatFlag() == 1) { return true; } if ("abnormal".equalsIgnoreCase(op.getInfraredStatus())) { return true; } return op.getPositionOffset() != null && op.getPositionOffset().compareTo(OFFSET_LIMIT) > 0; } private void matchOrder(UnattendedOperation op) { // 通过设备/货物信息匹配台账;命中即写入订单信息。 List> rows = Collections.emptyList(); if (StringUtils.hasText(op.getCarrierName())) { rows = unattendedOperationMapper.matchSalesOrder(op.getCarrierName()); if (rows == null || rows.isEmpty()) { rows = unattendedOperationMapper.matchPurchaseOrder(op.getCarrierName()); } } if (rows != null && !rows.isEmpty()) { Map row = rows.get(0); op.setOrderNo(Objects.toString(row.get("order_no"), null)); op.setOrderType(Objects.toString(row.get("order_type"), null)); op.setOrderMatched(1); } else { op.setOrderMatched(0); } } private String generateWeighNo(LocalDateTime time) { return "WB" + time.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + String.format("%03d", ThreadLocalRandom.current().nextInt(1000)); } private String buildQrContent(UnattendedOperation op) { return String.format("%s|%s|%s|%s|%s|%s", text(op.getWeighNo()), text(op.getPlateNumber()), op.getNetWeight() == null ? "" : op.getNetWeight().toPlainString(), op.getWeightTime() == null ? "" : op.getWeightTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), text(op.getCarrierName()), text(op.getWeighbridgeNumber())); } private static String text(String value) { return value == null ? "" : value; } private static BigDecimal scale(BigDecimal v) { return v == null ? BigDecimal.ZERO : v.setScale(2, RoundingMode.HALF_UP); } }