| | |
| | | package com.ruoyi.unattended.service.impl; |
| | | |
| | | |
| | | import com.baomidou.mybatisplus.core.metadata.IPage; |
| | | import com.baomidou.mybatisplus.core.toolkit.Wrappers; |
| | | import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.ruoyi.account.mapper.AccountReconciliationMapper; |
| | | import com.ruoyi.account.pojo.AccountReconciliation; |
| | | import com.ruoyi.account.service.AccountReconciliationService; |
| | | 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.util.List; |
| | | import java.util.Map; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.*; |
| | | import java.util.concurrent.ThreadLocalRandom; |
| | | import java.util.stream.Collectors; |
| | | |
| | | @AllArgsConstructor |
| | | @Slf4j |
| | | @Service |
| | | public class UnattendedOperationServiceImpl extends ServiceImpl<UnattendedOperationMapper, UnattendedOperation> implements UnattendedOperationService { |
| | | @AllArgsConstructor |
| | | public class UnattendedOperationServiceImpl |
| | | extends ServiceImpl<UnattendedOperationMapper, UnattendedOperation> |
| | | implements UnattendedOperationService { |
| | | |
| | | /** 红外偏移阈值(%),超过判定为车辆未完全上磅 */ |
| | | private static final BigDecimal OFFSET_LIMIT = new BigDecimal("5"); |
| | | |
| | | private UnattendedOperationMapper unattendedOperationMapper; |
| | | private final UnattendedOperationMapper unattendedOperationMapper; |
| | | |
| | | /** |
| | | * 每日过磅统计:按天聚合,货量取净重之和,偏差率取偏移超限车次占比。 |
| | | */ |
| | | @Override |
| | | public List<UnattendedOperationVo> weighbridgeDailyReport(LocalDateTime startDate, LocalDateTime endDate) { |
| | | List<UnattendedOperation> unattendedOperations = unattendedOperationMapper.selectList(Wrappers.<UnattendedOperation>lambdaQuery().between(UnattendedOperation::getWeightTime, startDate, endDate)); |
| | | Map<LocalDateTime, List<UnattendedOperation>> listMap = unattendedOperations.stream().collect(Collectors.groupingBy(UnattendedOperation::getWeightTime)); |
| | | List<UnattendedOperationVo> list = listMap.entrySet().stream().map(entry -> { |
| | | UnattendedOperationVo unattendedOperationVo = new UnattendedOperationVo(); |
| | | unattendedOperationVo.setDate(entry.getKey().toLocalDate()); |
| | | unattendedOperationVo.setTripCount((long) entry.getValue().size()); |
| | | unattendedOperationVo.setCargoVolume(entry.getValue().stream().map(UnattendedOperation::getGrossWeight).mapToLong(BigDecimal::longValue).sum()); |
| | | long sum = entry.getValue().stream().map(UnattendedOperation::getPositionOffset).mapToLong(BigDecimal::longValue).sum(); |
| | | unattendedOperationVo.setDeviationRate(BigDecimal.valueOf(sum).divide(BigDecimal.valueOf(entry.getValue().size()), 4, BigDecimal.ROUND_HALF_UP)); |
| | | return unattendedOperationVo; |
| | | }).collect(Collectors.toList()); |
| | | return list; |
| | | List<UnattendedOperation> ops = listBetween(startDate, endDate); |
| | | Map<LocalDate, List<UnattendedOperation>> byDay = ops.stream() |
| | | .filter(o -> o.getWeightTime() != null) |
| | | .collect(Collectors.groupingBy(o -> o.getWeightTime().toLocalDate(), TreeMap::new, Collectors.toList())); |
| | | |
| | | List<UnattendedOperationVo> result = new ArrayList<>(); |
| | | for (Map.Entry<LocalDate, List<UnattendedOperation>> entry : byDay.entrySet()) { |
| | | List<UnattendedOperation> 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<Map<String, Object>> carrierPerformance(LocalDateTime startDate, LocalDateTime endDate) { |
| | | List<UnattendedOperation> ops = listBetween(startDate, endDate); |
| | | Map<String, List<UnattendedOperation>> byCarrier = ops.stream() |
| | | .collect(Collectors.groupingBy( |
| | | o -> StringUtils.hasText(o.getCarrierName()) ? o.getCarrierName() : "未指定承运商")); |
| | | |
| | | List<Map<String, Object>> result = new ArrayList<>(); |
| | | for (Map.Entry<String, List<UnattendedOperation>> entry : byCarrier.entrySet()) { |
| | | List<UnattendedOperation> 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<String, Object> 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<String, Object> pushErp(Long id) { |
| | | UnattendedOperation op = getById(id); |
| | | if (op == null) { |
| | | throw new IllegalArgumentException("地磅记录不存在"); |
| | | } |
| | | Map<String, Object> 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<UnattendedOperation> listBetween(LocalDateTime start, LocalDateTime end) { |
| | | return unattendedOperationMapper.selectList( |
| | | Wrappers.<UnattendedOperation>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<Map<String, Object>> 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<String, Object> 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); |
| | | } |
| | | } |