运单更改单字段对比与追溯系统设计与实现

0 阅读7分钟

运单更改单字段对比与追溯系统设计与实现

一、业务背景与需求

在物流运单业务中,运单信息会因调度调整、客户需求变更等原因发生修改。为了精准追溯每一次变更的内容、时间和操作人员,需要设计一套运单更改单系统,核心需求包括:

  • 自动对比运单修改前后的对象差异,生成变更记录
  • 存储变更字段的新旧值、操作人、操作时间等信息
  • 支持按运单号查询变更历史,展示友好的中文描述
  • 兼容枚举值转换(如付款方式、是否可退等状态码转中文)

二、表结构设计

我们通过 oms_waybill_change 表存储每一条变更记录,核心字段如下:

字段名类型说明
idbigint主键ID
order_apply_idbigint更改单申请ID
order_novarchar(50)运单号(查询索引)
update_column_namevarchar(300)变更字段名(如dispatchOrder.totalCost
new_valuevarchar(500)新值
old_valuevarchar(500)旧值
creatervarchar(100)操作人
creater_timedatetime操作时间
is_deleteint逻辑删除标识(0-未删除)

设计要点:通过order_no建立索引,支持高效查询运单变更历史;update_column_name 存储带前缀的字段名(如dispatchOrder./goods./station.),用于后续映射中文描述。

三、核心流程梳理

整个运单更改单的核心流程分为 4 个阶段

  1. 对象对比:传入旧对象(oldDetail)和新对象(newDetail),通过反射工具类对比属性差异
  2. 记录生成:将差异结果转换为WaybillChange变更记录
  3. 枚举映射:将状态码(如0/1)转换为中文描述(如是/否
  4. 存储与查询:批量保存变更记录,支持按运单号查询并展示中文描述

暂时无法在豆包文档外展示此内容

四、代码优化与实现

1. 核心工具类:Bean 差异对比

BeanConstrastUtils通过反射实现对象属性对比,我们优化了可读性、边界处理和扩展性

/**
 * 对比两个Bean属性差异工具类
 * 核心能力:支持嵌套对象、List集合、数值精度处理,结合枚举过滤有效变更字段
 */
public class BeanConstrastUtils {

    private static final Logger LOGGER = LoggerFactory.getLogger(BeanConstrastUtils.class);

    // 忽略的冗余字段(与业务变更无关)
    private static final List<String> IGNORE_FIELDS = List.of("directEstimatePriceResp","priceCheck","stations","goods","changeSource");
    // 忽略的扩展字段
    private static final List<String> IGNORE_EXT_FIELDS = List.of("column16", "column17", "column18");
    // 需要保留4位小数的数值字段
    private static final List<String> FOUR_POINT_FIELDS = List.of("realWeight", "volume", "billingWeight", "billingVolume");

    /**
     * 对比两个对象的属性差异
     * @param source 变更前对象
     * @param target 变更后对象
     * @param isAdd 是否为新增场景(List对比时使用)
     * @return 差异结果集合
     */
    public static List<ConstrastObjBean> constrast(Object source, Object target, boolean isAdd) throws Exception {
        // 空值防护
        if (source == null || target == null) {
            return Collections.emptyList();
        }

        // 处理List类型对比
        if (source instanceof List && target instanceof List) {
            return constrastList((List<?>) source, (List<?>) target, isAdd);
        }

        // 类型校验:确保两个对象是同类型或父子类关系
        if (!source.getClass().isAssignableFrom(target.getClass())) {
            return Collections.emptyList();
        }

        return constrastObject(source, target, isAdd, 0);
    }

    /**
     * 对比List集合中元素的差异(支持新增/删除元素)
     */
    private static List<ConstrastObjBean> constrastList(List<?> sourceList, List<?> targetList, boolean isAdd) throws Exception {
        List<ConstrastObjBean> beanList = new ArrayList<>();
        int minSize = Math.min(sourceList.size(), targetList.size());

        // 对比共同长度部分的元素
        for (int i = 0; i < minSize; i++) {
            Object sourceItem = sourceList.get(i);
            Object targetItem = targetList.get(i);
            if (sourceItem != null && targetItem != null) {
                beanList.addAll(constrastObject(sourceItem, targetItem, isAdd, i));
            }
        }

        // 处理新增/删除的元素
        if (targetList.size() > sourceList.size()) {
            for (int i = sourceList.size(); i < targetList.size(); i++) {
                beanList.addAll(constrastObject(null, targetList.get(i), true, i));
            }
        } else if (sourceList.size() > targetList.size()) {
            for (int i = targetList.size(); i < sourceList.size(); i++) {
                beanList.addAll(constrastObject(sourceList.get(i), null, false, i));
            }
        }
        return beanList;
    }

    /**
     * 单个对象属性对比核心逻辑
     */
    private static List<ConstrastObjBean> constrastObject(Object source, Object target, boolean isAdd, int index) throws Exception {
        Map<Object, Object> oldValueMap = new HashMap<>();
        Map<Object, Object> newValueMap = new HashMap<>();
        Class<?> clazz = source != null ? source.getClass() : target.getClass();
        Field[] fields = clazz.getDeclaredFields();

        for (Field field : fields) {
            // 跳过合成字段和序列化ID
            if (field.isSynthetic() || "serialVersionUID".equals(field.getName())) continue;
            // 跳过业务无关字段
            if (IGNORE_FIELDS.contains(field.getName()) || IGNORE_EXT_FIELDS.contains(field.getName())) continue;

            field.setAccessible(true);
            PropertyDescriptor pd = new PropertyDescriptor(field.getName(), clazz);
            Method readMethod = pd.getReadMethod();

            // 获取新旧值
            Object sourceValue = source != null ? readMethod.invoke(source) : null;
            Object targetValue = target != null ? readMethod.invoke(target) : null;

            // 数值类型精度处理
            String val1 = formatNumberValue(sourceValue, field.getName());
            String val2 = formatNumberValue(targetValue, field.getName());

            // 生成带前缀的字段Key(用于映射中文描述)
            String fieldKey = getFieldKey(source != null ? source : target, field.getName(), index);
            // 过滤无效变更(不在枚举定义内的字段不记录)
            if (OmsRefreshItemEnum.getByCode(fieldKey) == OmsRefreshItemEnum.NOT_EXIST) continue;

            // 记录差异
            if (!Objects.equals(val1, val2)) {
                oldValueMap.put(fieldKey, val1);
                newValueMap.put(fieldKey, val2);
            }
        }

        // 封装差异结果
        if (!oldValueMap.isEmpty()) {
            ConstrastObjBean bean = new ConstrastObjBean();
            bean.setBefore(oldValueMap);
            bean.setAfter(newValueMap);
            return Collections.singletonList(bean);
        }
        return Collections.emptyList();
    }

    /**
     * 格式化数值类型值(处理小数精度)
     */
    private static String formatNumberValue(Object value, String fieldName) {
        if (value == null) return "";
        if (!(value instanceof Number)) return value.toString();

        int scale = FOUR_POINT_FIELDS.contains(fieldName) ? 4 : 3;
        return new BigDecimal(value.toString()).setScale(scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 生成带业务前缀的字段Key(如dispatchOrder.totalCost)
     */
    private static String getFieldKey(Object obj, String fieldName, int index) {
        String prefix = "";
        if (DirectOrderSaveVO.class.isAssignableFrom(obj.getClass())) prefix = "dispatchOrder.";
        else if (DispatchStationVO.class.isAssignableFrom(obj.getClass())) prefix = "station.";
        else if (DispatchOrderGoodsVO.class.isAssignableFrom(obj.getClass())) prefix = "goods.";
        return prefix + fieldName;
    }
}

2. 业务逻辑层:变更记录生成与映射

优化后compareUpdate方法职责更清晰,拆分了对象对比、记录转换、枚举映射三个核心步骤:

/**
 * 运单更改单核心服务类
 * 负责新旧对象对比、变更记录生成、枚举值映射与批量存储
 */
@Service
public class WaybillChangeServiceImpl extends ServiceImpl<WaybillChangeMapper, WaybillChange> implements IWaybillChangeService {

    /**
     * 对比新旧对象,生成变更记录
     * @param oldDetail 变更前运单对象
     * @param newDetail 变更后运单对象
     * @param orderNo 运单号
     * @return 变更记录集合
     */
    public List<WaybillChange> compareUpdate(DirectOrderSaveVO oldDetail, DirectOrderSaveVO newDetail, String orderNo) throws Exception {
        List<WaybillChange> changeList = new ArrayList<>();

        // 1. 对比货物信息差异
        if (newDetail.getGoods() != null) {
            changeList.addAll(convertToChangeRecords(
                BeanConstrastUtils.constrast(oldDetail.getGoods(), newDetail.getGoods())
            ));
        }

        // 2. 对比运单主信息差异
        changeList.addAll(convertToChangeRecords(
            BeanConstrastUtils.constrast(oldDetail, newDetail)
        ));

        // 3. 对比站点信息差异
        DispatchStationVO oldStation = getDispatchStationVO(oldDetail.getStations());
        DispatchStationVO newStation = getDispatchStationVO(newDetail.getStations());
        if (newStation != null) {
            changeList.addAll(convertToChangeRecords(
                BeanConstrastUtils.constrast(oldStation, newStation)
            ));
        }

        // 4. 枚举值映射(状态码转中文)
        enumCodeMapping(changeList, orderNo);
        log.info("运单{}变更记录:{}", orderNo, JSON.toJSONString(changeList));
        return changeList;
    }

    /**
     * 将对比结果转换为WaybillChange变更记录
     */
    private List<WaybillChange> convertToChangeRecords(List<ConstrastObjBean> contrastList) {
        List<WaybillChange> records = new ArrayList<>();
        for (ConstrastObjBean bean : contrastList) {
            for (Map.Entry<Object, Object> entry : bean.getBefore().entrySet()) {
                WaybillChange record = new WaybillChange();
                record.setUpdateColumnName((String) entry.getKey());
                record.setOldValue(entry.getValue() == null ? null : entry.getValue().toString());
                record.setNewValue(bean.getAfter().get(entry.getKey()) == null ? null : bean.getAfter().get(entry.getKey()).toString());
                records.add(record);
            }
        }
        return records;
    }

    /**
     * 提取站点信息(拆分发件/收件站点为统一对象)
     */
    private DispatchStationVO getDispatchStationVO(List<DispatchOrderStationVO> stationList) {
        DispatchStationVO stationVO = new DispatchStationVO();
        if (CollectionUtils.isEmpty(stationList)) return stationVO;

        for (DispatchOrderStationVO station : stationList) {
            if (DisOrderStationTypeEnum.LOADING.getValue().equals(station.getStationType())) {
                // 封装发件站点信息
                stationVO.setSrcProvinceName(station.getProvinceName());
                stationVO.setSrcCityName(station.getCityName());
                // ... 其他发件字段
            } else if (DisOrderStationTypeEnum.UNLOAD.getValue().equals(station.getStationType())) {
                // 封装收件站点信息
                stationVO.setDestProvinceName(station.getProvinceName());
                stationVO.setDestCityName(station.getCityName());
                // ... 其他收件字段
            }
        }
        return stationVO;
    }

    /**
     * 枚举值映射:将状态码转换为中文描述
     * 优化点:通过策略模式替代大量if判断,提升扩展性
     */
    private void enumCodeMapping(List<WaybillChange> changeList, String orderNo) {
        changeList.forEach(record -> {
            record.setOrderNo(orderNo);
            String columnName = record.getUpdateColumnName();
            OmsRefreshItemEnum itemEnum = OmsRefreshItemEnum.getByCode(columnName);

            switch (itemEnum) {
                case depositRefundable, invoicingFlag, exDispatchFlag -> {
                    // 是/否类型枚举映射
                    record.setNewValue(YesOrNoEnum.getEnum(record.getNewValue()).getDesc());
                    record.setOldValue(YesOrNoEnum.getEnum(record.getOldValue()).getDesc());
                }
                case receiptType -> {
                    // 回单类型枚举映射
                    record.setNewValue(ReceiptTypeEnum.getByCode(record.getNewValue()).getName());
                    record.setOldValue(ReceiptTypeEnum.getByCode(record.getOldValue()).getName());
                }
                case paymentType -> {
                    // 付款方式枚举映射
                    record.setNewValue(DisOrderPaymentTypeEnum.getEnumByValue(record.getNewValue()).getDesc());
                    record.setOldValue(DisOrderPaymentTypeEnum.getEnumByValue(record.getOldValue()).getDesc());
                }
                case deliveryMethod -> {
                    // 送货方式枚举映射
                    record.setNewValue(DeliveryMethodEnum.getEnumByValue(record.getNewValue()).getDesc());
                    record.setOldValue(DeliveryMethodEnum.getByCode(record.getOldValue()).getDesc());
                }
                default -> {}
            }
        });
    }

    // ------------------- 查询接口实现 -------------------
    @Override
    public List<OmsChangeOrderVo> listChangeOrder(String orderNo) {
        // 1. 查询未删除的变更记录(按时间倒序)
        List<WaybillChange> changeList = this.lambdaQuery()
                .eq(WaybillChange::getOrderNo, orderNo)
                .eq(WaybillChange::getIsDelete, 0)
                .orderByDesc(WaybillChange::getCreaterTime)
                .list();

        // 2. 转换为前端展示VO
        return changeList.stream().map(change -> {
            OmsChangeOrderVo vo = new OmsChangeOrderVo();
            vo.setOrderNo(change.getOrderNo());
            vo.setItemEn(change.getUpdateColumnName());
            vo.setItemCh(getChangeItemChinese(change.getUpdateColumnName()));
            vo.setNewValue(change.getNewValue());
            vo.setOldValue(change.getOldValue());
            vo.setCreaterTime(change.getCreaterTime());
            return vo;
        }).collect(Collectors.toList());
    }

    /**
     * 将英文字段名映射为中文描述
     */
    private String getChangeItemChinese(String enName) {
        if (StringUtils.isBlank(enName)) return "";
        // 匹配业务字段枚举
        if (enName.startsWith("dispatchOrder.") || enName.startsWith("goods.") || enName.startsWith("station.")) {
            return OmsRefreshItemEnum.getByCode(enName).getName();
        }
        // 匹配费用项字段
        if (enName.startsWith("serviceList")) {
            String serviceType = enName.substring(enName.indexOf("'") + 1, enName.lastIndexOf("'"));
            String property = enName.substring(enName.lastIndexOf(".") + 1);
            return "inFeeAmt".equals(property) ? ServiceFeeTypeEnum.getByCode(serviceType).getName() : FeeAttrColumnEnum.getByCode(enName).getName();
        }
        return "";
    }
}

3. 控制层:查询接口实现

@RestController
@RequestMapping("/waybill/change")
@Tag(name = "订单更改单", description = "运单变更历史查询API")
public class WaybillChangeController {

    @Autowired
    private IWaybillChangeService waybillChangeService;

    /**
     * 根据运单号查询变更历史
     * @param orderNo 运单号
     * @return 变更记录集合(含中文描述)
     */
    @GetMapping("/listChangeOrder")
    @Operation(summary = "按运单号查询变更历史", description = "返回该运单所有未删除的变更记录,按时间倒序")
    public WebResponse<List<OmsChangeOrderVo>> listChangeOrder(@RequestParam("orderNo") String orderNo) {
        if (StringUtils.isBlank(orderNo)) {
            return WebResponseUtil.fail.build("运单号不能为空");
        }
        return WebResponseUtil.success.build(waybillChangeService.listChangeOrder(orderNo));
    }
}

五、效果展示

1. 数据库存储效果

  • 每条记录对应一个字段的变更,包含新旧值、操作时间等信息
  • update_column_name 存储带前缀的字段名,用于后续映射中文描述

2. 接口返回效果

  • 前端可直接展示中文字段名(如总费用承运司机车牌号
  • 清晰展示变更前后的值和操作时间,方便业务追溯

六、总结与优化点

核心优化点

  1. 职责拆分:将对比、转换、映射逻辑拆分为独立方法,提升代码可读性和可维护性
  2. 健壮性增强:增加空值防护、边界处理,避免NPE和异常崩溃
  3. 扩展性提升:通过枚举和策略模式处理状态码映射,新增枚举类型无需修改核心逻辑
  4. 性能优化:批量存储变更记录,减少数据库交互次数

后续可扩展方向

  • 增加操作人信息记录,关联操作人员ID和姓名
  • 支持变更版本管理,按申请ID分组展示多次变更
  • 接入消息通知,将关键变更推送给相关业务人员
  • 增加变更审计,记录变更操作的IP、终端等信息

image.png