result = new HashMap<>();
result.put("success", false);
@@ -5596,6 +5608,7 @@ public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService {
*/
@Override
@Transactional(rollbackFor = Exception.class)
+ @CoilChangeLog(value = "创建子卷", changeType = CoilChangeType.SPLIT, coilIdParam = "parentCoilId")
public WmsMaterialCoilVo createChildCoilInSpecialSplit(Long parentCoilId, WmsMaterialCoilBo childCoilBo, Long pendingActionId) {
// 1. 查询母卷信息
WmsMaterialCoil parentCoil = baseMapper.selectById(parentCoilId);
@@ -5921,6 +5934,7 @@ public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService {
*/
@Override
@Transactional(rollbackFor = Exception.class)
+ @CoilChangeLog(value = "退货", changeType = CoilChangeType.PROCESS)
public WmsMaterialCoilVo returnCoil(Long coilId) {
if (coilId == null) {
throw new RuntimeException("钢卷ID不能为空");
@@ -6200,6 +6214,7 @@ public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService {
}
@Override
+ @CoilChangeLog(value = "修复材质异常", changeType = CoilChangeType.UPDATE)
public Boolean fixMaterialMismatchCoils(Long coilId) {
WmsMaterialCoil coil = baseMapper.selectById(coilId);
if (coil == null) {
@@ -6616,6 +6631,7 @@ public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService {
}
@Override
+ @CoilChangeLog(value = "解锁钢卷", changeType = CoilChangeType.UPDATE)
public Boolean unlockCoil(Long coilId) {
if (coilId == null) {
throw new RuntimeException("钢卷ID不能为空");
diff --git a/klp-wms/src/main/java/com/klp/service/impl/WmsTransferOrderItemServiceImpl.java b/klp-wms/src/main/java/com/klp/service/impl/WmsTransferOrderItemServiceImpl.java
index 43fb5ca9d..d87eba580 100644
--- a/klp-wms/src/main/java/com/klp/service/impl/WmsTransferOrderItemServiceImpl.java
+++ b/klp-wms/src/main/java/com/klp/service/impl/WmsTransferOrderItemServiceImpl.java
@@ -9,6 +9,8 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.toolkit.Db;
import com.klp.common.utils.StringUtils;
+import com.klp.wms.annotation.CoilChangeLog;
+import com.klp.wms.enums.CoilChangeType;
import com.klp.domain.*;
import com.klp.domain.bo.WmsMaterialCoilBo;
import com.klp.domain.vo.WmsMaterialCoilVo;
@@ -529,6 +531,7 @@ public class WmsTransferOrderItemServiceImpl implements IWmsTransferOrderItemSer
*/
@Override
@Transactional(rollbackFor = Exception.class)
+ @CoilChangeLog(value = "确认调拨", changeType = CoilChangeType.UPDATE, coilIdBoField = "coilId")
public Boolean confirmTransfer(WmsTransferOrderItemBo bo) {
Long transferId = bo.getTransferId();
Long orderItemId = bo.getOrderItemId();
diff --git a/klp-wms/src/main/java/com/klp/wms/annotation/CoilChangeLog.java b/klp-wms/src/main/java/com/klp/wms/annotation/CoilChangeLog.java
new file mode 100644
index 000000000..0fe4c2de2
--- /dev/null
+++ b/klp-wms/src/main/java/com/klp/wms/annotation/CoilChangeLog.java
@@ -0,0 +1,34 @@
+package com.klp.wms.annotation;
+
+import com.klp.wms.enums.CoilChangeType;
+
+import java.lang.annotation.*;
+
+/**
+ * 钢卷变更日志注解
+ *
+ * 标注在修改 wms_material_coil 的方法上,AOP 切面自动记录变更前后的差异。
+ *
+ * @author klp
+ * @date 2026-07-11
+ */
+@Target({ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface CoilChangeLog {
+
+ /** 操作描述,如"钢卷加工"、"质量改判" */
+ String value();
+
+ /** 变更类型,默认 UPDATE */
+ CoilChangeType changeType() default CoilChangeType.UPDATE;
+
+ /** 方法参数中 coilId 的参数名(直接传 Long coilId 时使用) */
+ String coilIdParam() default "coilId";
+
+ /** BO 对象中 coilId 的字段名(传 BO 对象时使用,如 "coilId" 对应 bo.getCoilId()) */
+ String coilIdBoField() default "";
+
+ /** 是否批量操作(多个 coilId 时每个 ID 各写一条日志) */
+ boolean isBatch() default false;
+}
\ No newline at end of file
diff --git a/klp-wms/src/main/java/com/klp/wms/aspect/CoilChangeLogAspect.java b/klp-wms/src/main/java/com/klp/wms/aspect/CoilChangeLogAspect.java
new file mode 100644
index 000000000..bfa7ca853
--- /dev/null
+++ b/klp-wms/src/main/java/com/klp/wms/aspect/CoilChangeLogAspect.java
@@ -0,0 +1,341 @@
+package com.klp.wms.aspect;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.json.JSONUtil;
+import com.klp.domain.WmsMaterialCoil;
+import com.klp.domain.WmsCoilChangeLog;
+import com.klp.mapper.WmsCoilChangeLogMapper;
+import com.klp.mapper.WmsMaterialCoilMapper;
+import com.klp.wms.annotation.CoilChangeLog;
+import com.klp.wms.enums.CoilChangeType;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+import java.util.*;
+
+/**
+ * 钢卷变更日志切面
+ *
+ * 拦截标注了 @CoilChangeLog 的方法,记录变更前后的差异。
+ *
+ * @author klp
+ * @date 2026-07-11
+ */
+@Slf4j
+@Aspect
+@Component
+@RequiredArgsConstructor
+public class CoilChangeLogAspect {
+
+ private final WmsMaterialCoilMapper coilMapper;
+ private final WmsCoilChangeLogMapper changeLogMapper;
+
+ /** 不需要参与差异比较的字段 */
+ private static final Set EXCLUDE_DIFF_FIELDS = new HashSet<>(Arrays.asList(
+ "coilId", "createBy", "createTime", "updateBy", "updateTime",
+ "delFlag", "searchValue", "params"
+ ));
+
+ @Around("@annotation(coilChangeLog)")
+ public Object around(ProceedingJoinPoint joinPoint, CoilChangeLog coilChangeLog) throws Throwable {
+ CoilChangeType changeType = coilChangeLog.changeType();
+
+ // 1. 提取旧卷 ID 列表
+ List oldCoilIds = extractOldCoilIds(joinPoint, coilChangeLog);
+
+ // 2. 查询旧卷快照
+ Map> oldSnapshots = new LinkedHashMap<>();
+ for (Long coilId : oldCoilIds) {
+ if (coilId != null) {
+ WmsMaterialCoil coil = coilMapper.selectById(coilId);
+ if (coil != null) {
+ oldSnapshots.put(coilId, beanToSnapshot(coil));
+ }
+ }
+ }
+
+ // 3. 执行目标方法
+ Object result = joinPoint.proceed();
+
+ // 4. 确定新卷 ID 列表
+ List newCoilIds = resolveNewCoilIds(result, changeType, oldCoilIds);
+
+ // 5. 查询新卷快照并写入日志(try-catch 包裹,不影响主流程)
+ try {
+ if (changeType == CoilChangeType.DELETE) {
+ // 删除:只有旧快照,无新快照
+ for (Long oldId : oldCoilIds) {
+ Map oldSnapshot = oldSnapshots.get(oldId);
+ insertLog(changeType, oldId, null, oldSnapshot, null, coilChangeLog.value());
+ }
+ } else if (changeType == CoilChangeType.INSERT) {
+ // 新建:无旧快照,只有新快照
+ for (Long newId : newCoilIds) {
+ Map newSnapshot = queryCoilSnapshot(newId);
+ insertLog(changeType, null, newId, null, newSnapshot, coilChangeLog.value());
+ }
+ } else if (changeType.isProcessing()) {
+ // 加工变换:旧卷 → 新卷,一一对应(或 多旧 → 一新)
+ for (int i = 0; i < oldCoilIds.size(); i++) {
+ Long oldId = oldCoilIds.get(i);
+ Long newId = (i < newCoilIds.size()) ? newCoilIds.get(i) : null;
+ Map oldSnapshot = oldSnapshots.get(oldId);
+ Map newSnapshot = (newId != null) ? queryCoilSnapshot(newId) : null;
+ insertLog(changeType, oldId, newId, oldSnapshot, newSnapshot, coilChangeLog.value());
+ }
+ } else {
+ // 原地修改:相同 ID
+ for (Long coilId : oldCoilIds) {
+ Map oldSnapshot = oldSnapshots.get(coilId);
+ Map newSnapshot = queryCoilSnapshot(coilId);
+ insertLog(changeType, coilId, coilId, oldSnapshot, newSnapshot, coilChangeLog.value());
+ }
+ }
+ } catch (Exception e) {
+ log.warn("记录钢卷变更日志失败: {}", e.getMessage());
+ }
+
+ return result;
+ }
+
+ // ==================== 参数提取 ====================
+
+ /**
+ * 从方法参数中提取旧卷 ID 列表
+ */
+ private List extractOldCoilIds(ProceedingJoinPoint joinPoint, CoilChangeLog annotation) {
+ Object[] args = joinPoint.getArgs();
+ MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+ String[] paramNames = signature.getParameterNames();
+
+ List ids = new ArrayList<>();
+
+ // 优先从 BO 字段提取
+ String boField = annotation.coilIdBoField();
+ if (boField != null && !boField.isEmpty()) {
+ for (Object arg : args) {
+ if (arg != null && !isBasicType(arg)) {
+ Long id = getFieldValue(arg, boField);
+ if (id != null) {
+ // bo.getCoilId() 非空 → 分卷场景:外层就是旧母卷
+ ids.add(id);
+ } else {
+ // bo.getCoilId() 为空 → 合卷场景:newCoils 里的才是旧卷
+ List> newCoils = getFieldValue(arg, "newCoils", List.class);
+ if (newCoils != null) {
+ for (Object item : newCoils) {
+ Long itemId = getFieldValue(item, boField);
+ if (itemId != null) {
+ ids.add(itemId);
+ }
+ }
+ }
+ }
+ }
+ }
+ if (!ids.isEmpty()) {
+ return ids;
+ }
+ }
+
+ // 按参数名提取
+ String paramName = annotation.coilIdParam();
+ for (int i = 0; i < paramNames.length; i++) {
+ if (paramName.equals(paramNames[i])) {
+ Object val = args[i];
+ if (val instanceof Long) {
+ ids.add((Long) val);
+ } else if (val instanceof Collection) {
+ for (Object item : (Collection>) val) {
+ if (item instanceof Long) {
+ ids.add((Long) item);
+ }
+ }
+ }
+ }
+ }
+
+ return ids;
+ }
+
+ // ==================== 新卷 ID 解析 ====================
+
+ /**
+ * 从方法返回值中解析新卷 ID
+ */
+ private List resolveNewCoilIds(Object result, CoilChangeType changeType, List oldCoilIds) {
+ List newIds = new ArrayList<>();
+
+ if (result == null) {
+ return newIds;
+ }
+
+ if (result instanceof Long) {
+ newIds.add((Long) result);
+ } else if (result instanceof Integer) {
+ newIds.add(((Integer) result).longValue());
+ } else if (result instanceof String) {
+ // 可能是单个 ID 或逗号分隔的 ID 列表
+ String str = (String) result;
+ if (str.contains(",")) {
+ for (String part : str.split(",")) {
+ try {
+ newIds.add(Long.parseLong(part.trim()));
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ } else {
+ try {
+ newIds.add(Long.parseLong(str.trim()));
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ } else if (result instanceof Map) {
+ // 尝试从 Map 中提取 coilId 或 newCoilId
+ Map, ?> map = (Map, ?>) result;
+ Object id = map.get("coilId");
+ if (id == null) {
+ id = map.get("newCoilId");
+ }
+ if (id instanceof Long) {
+ newIds.add((Long) id);
+ }
+ } else {
+ // 尝试反射获取 coilId
+ Long id = getFieldValue(result, "coilId");
+ if (id != null) {
+ newIds.add(id);
+ }
+ }
+
+ // 如果无法解析新 ID,对于非加工类型,使用旧 ID
+ if (newIds.isEmpty() && !changeType.isProcessing() && changeType != CoilChangeType.DELETE) {
+ return oldCoilIds;
+ }
+
+ return newIds;
+ }
+
+ // ==================== 快照与差异 ====================
+
+ /**
+ * 将钢卷实体转为快照 Map(排除无关字段)
+ */
+ private Map beanToSnapshot(WmsMaterialCoil coil) {
+ Map map = BeanUtil.beanToMap(coil, false, true);
+ // 转换为字符串值以便 JSON 序列化
+ Map snapshot = new LinkedHashMap<>();
+ for (Map.Entry entry : map.entrySet()) {
+ if (!EXCLUDE_DIFF_FIELDS.contains(entry.getKey())) {
+ snapshot.put(entry.getKey(), entry.getValue());
+ }
+ }
+ return snapshot;
+ }
+
+ /**
+ * 按 ID 查询钢卷并转为快照
+ */
+ private Map queryCoilSnapshot(Long coilId) {
+ if (coilId == null) {
+ return Collections.emptyMap();
+ }
+ WmsMaterialCoil coil = coilMapper.selectById(coilId);
+ return coil != null ? beanToSnapshot(coil) : Collections.emptyMap();
+ }
+
+ /**
+ * 对比新旧快照,返回变更字段差异 Map
+ */
+ private Map> computeDiff(Map oldSnapshot,
+ Map newSnapshot) {
+ Map> diff = new LinkedHashMap<>();
+
+ // 检查旧值变更或新增
+ Set allFields = new LinkedHashSet<>();
+ if (oldSnapshot != null) allFields.addAll(oldSnapshot.keySet());
+ if (newSnapshot != null) allFields.addAll(newSnapshot.keySet());
+
+ for (String field : allFields) {
+ if (EXCLUDE_DIFF_FIELDS.contains(field)) {
+ continue;
+ }
+ Object oldVal = (oldSnapshot != null) ? oldSnapshot.get(field) : null;
+ Object newVal = (newSnapshot != null) ? newSnapshot.get(field) : null;
+
+ if (!Objects.equals(oldVal, newVal)) {
+ Map change = new LinkedHashMap<>();
+ change.put("old", oldVal);
+ change.put("new", newVal);
+ diff.put(field, change);
+ }
+ }
+
+ return diff;
+ }
+
+ // ==================== 日志写入 ====================
+
+ private void insertLog(CoilChangeType changeType, Long oldCoilId, Long newCoilId,
+ Map oldSnapshot, Map newSnapshot,
+ String operationDesc) {
+ Map> diff = computeDiff(oldSnapshot, newSnapshot);
+
+ WmsCoilChangeLog logRecord = new WmsCoilChangeLog();
+ logRecord.setChangeType(changeType.name());
+ logRecord.setOldCoilId(oldCoilId);
+ logRecord.setNewCoilId(newCoilId);
+ logRecord.setOldCoilNo(snapshotValue(oldSnapshot, "currentCoilNo"));
+ logRecord.setNewCoilNo(snapshotValue(newSnapshot, "currentCoilNo"));
+ logRecord.setChangedFields(diff.isEmpty() ? null : JSONUtil.toJsonStr(diff));
+ logRecord.setOperationDesc(operationDesc);
+
+ changeLogMapper.insert(logRecord);
+ }
+
+ private String snapshotValue(Map snapshot, String key) {
+ if (snapshot == null) return null;
+ Object val = snapshot.get(key);
+ return val != null ? String.valueOf(val) : null;
+ }
+
+ // ==================== 反射工具 ====================
+
+ private Long getFieldValue(Object obj, String fieldName) {
+ try {
+ String getter = "get" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);
+ Method method = obj.getClass().getMethod(getter);
+ Object val = method.invoke(obj);
+ if (val instanceof Long) {
+ return (Long) val;
+ }
+ } catch (Exception ignored) {
+ }
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private T getFieldValue(Object obj, String fieldName, Class type) {
+ try {
+ String getter = "get" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);
+ Method method = obj.getClass().getMethod(getter);
+ Object val = method.invoke(obj);
+ if (type.isInstance(val)) {
+ return (T) val;
+ }
+ } catch (Exception ignored) {
+ }
+ return null;
+ }
+
+ private boolean isBasicType(Object obj) {
+ return obj instanceof String || obj instanceof Number
+ || obj instanceof Boolean || obj instanceof Date;
+ }
+}
diff --git a/klp-wms/src/main/java/com/klp/wms/enums/CoilChangeType.java b/klp-wms/src/main/java/com/klp/wms/enums/CoilChangeType.java
new file mode 100644
index 000000000..d39fb6811
--- /dev/null
+++ b/klp-wms/src/main/java/com/klp/wms/enums/CoilChangeType.java
@@ -0,0 +1,44 @@
+package com.klp.wms.enums;
+
+/**
+ * 钢卷变更类型
+ *
+ * @author klp
+ * @date 2026-07-11
+ */
+public enum CoilChangeType {
+
+ /** 普通修改(原地修改字段) */
+ UPDATE,
+
+ /** 新建钢卷 */
+ INSERT,
+
+ /** 加工(旧卷变历史,生成新卷) */
+ PROCESS,
+
+ /** 分卷 */
+ SPLIT,
+
+ /** 合卷 */
+ MERGE,
+
+ /** 删除 */
+ DELETE,
+
+ /** 回滚 */
+ ROLLBACK,
+
+ /** 发货 */
+ EXPORT,
+
+ /** 质量改判 */
+ QUALITY_CHANGE;
+
+ /**
+ * 是否为加工变换类型(旧卷 → 新卷)
+ */
+ public boolean isProcessing() {
+ return this == PROCESS || this == SPLIT || this == MERGE || this == ROLLBACK;
+ }
+}
\ No newline at end of file
diff --git a/klp-wms/src/main/resources/mapper/klp/WmsCoilChangeLogMapper.xml b/klp-wms/src/main/resources/mapper/klp/WmsCoilChangeLogMapper.xml
new file mode 100644
index 000000000..9abdcaca6
--- /dev/null
+++ b/klp-wms/src/main/resources/mapper/klp/WmsCoilChangeLogMapper.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+