运单更改单字段对比与追溯系统设计与实现
一、业务背景与需求
在物流运单业务中,运单信息会因调度调整、客户需求变更等原因发生修改。为了精准追溯每一次变更的内容、时间和操作人员,需要设计一套运单更改单系统,核心需求包括:
- 自动对比运单修改前后的对象差异,生成变更记录
- 存储变更字段的新旧值、操作人、操作时间等信息
- 支持按运单号查询变更历史,展示友好的中文描述
- 兼容枚举值转换(如付款方式、是否可退等状态码转中文)
二、表结构设计
我们通过 oms_waybill_change 表存储每一条变更记录,核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键ID |
| order_apply_id | bigint | 更改单申请ID |
| order_no | varchar(50) | 运单号(查询索引) |
| update_column_name | varchar(300) | 变更字段名(如dispatchOrder.totalCost) |
| new_value | varchar(500) | 新值 |
| old_value | varchar(500) | 旧值 |
| creater | varchar(100) | 操作人 |
| creater_time | datetime | 操作时间 |
| is_delete | int | 逻辑删除标识(0-未删除) |
设计要点:通过order_no建立索引,支持高效查询运单变更历史;update_column_name 存储带前缀的字段名(如dispatchOrder./goods./station.),用于后续映射中文描述。
三、核心流程梳理
整个运单更改单的核心流程分为 4 个阶段:
- 对象对比:传入旧对象(
oldDetail)和新对象(newDetail),通过反射工具类对比属性差异 - 记录生成:将差异结果转换为
WaybillChange变更记录 - 枚举映射:将状态码(如
0/1)转换为中文描述(如是/否) - 存储与查询:批量保存变更记录,支持按运单号查询并展示中文描述
暂时无法在豆包文档外展示此内容
四、代码优化与实现
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. 接口返回效果
- 前端可直接展示中文字段名(如
总费用、承运司机车牌号) - 清晰展示变更前后的值和操作时间,方便业务追溯
六、总结与优化点
核心优化点
- 职责拆分:将对比、转换、映射逻辑拆分为独立方法,提升代码可读性和可维护性
- 健壮性增强:增加空值防护、边界处理,避免NPE和异常崩溃
- 扩展性提升:通过枚举和策略模式处理状态码映射,新增枚举类型无需修改核心逻辑
- 性能优化:批量存储变更记录,减少数据库交互次数
后续可扩展方向
- 增加操作人信息记录,关联操作人员ID和姓名
- 支持变更版本管理,按申请ID分组展示多次变更
- 接入消息通知,将关键变更推送给相关业务人员
- 增加变更审计,记录变更操作的IP、终端等信息