diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/constant/ReturnOrderStatus.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/constant/ReturnOrderStatus.java
new file mode 100644
index 0000000..22ed2c9
--- /dev/null
+++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/constant/ReturnOrderStatus.java
@@ -0,0 +1,22 @@
+package com.ruoyi.asset.constant;
+
+/**
+ * 退库单状态常量
+ *
+ * @author Yangk
+ */
+public final class ReturnOrderStatus
+{
+ /** 草稿 */
+ public static final String DRAFT = "DRAFT";
+
+ /** 待确认 */
+ public static final String PENDING_CONFIRM = "PENDING_CONFIRM";
+
+ /** 已退库 */
+ public static final String RETURNED = "RETURNED";
+
+ private ReturnOrderStatus()
+ {
+ }
+}
diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/controller/AmsReturnOrderController.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/controller/AmsReturnOrderController.java
new file mode 100644
index 0000000..e15e66a
--- /dev/null
+++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/controller/AmsReturnOrderController.java
@@ -0,0 +1,242 @@
+package com.ruoyi.asset.controller;
+
+import java.util.List;
+import com.ruoyi.asset.domain.AmsAsset;
+import com.ruoyi.asset.domain.AmsAssetLocation;
+import com.ruoyi.asset.domain.AmsReturnOrder;
+import com.ruoyi.asset.domain.AmsWarehouse;
+import com.ruoyi.asset.service.IAmsAssetLocationService;
+import com.ruoyi.asset.service.IAmsReturnOrderService;
+import com.ruoyi.asset.service.IAmsWarehouseService;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.constant.UserConstants;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.domain.entity.SysDept;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.system.service.ISysDeptService;
+import org.apache.shiro.authz.annotation.Logical;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+/**
+ * 退库管理Controller
+ *
+ * @author Yangk
+ */
+@Controller
+@RequestMapping("/asset/return")
+public class AmsReturnOrderController extends BaseController
+{
+ private static final String ENABLED_YES = "Y";
+
+ private String prefix = "asset/return";
+
+ @Autowired
+ private IAmsReturnOrderService amsReturnOrderService;
+
+ @Autowired
+ private IAmsWarehouseService amsWarehouseService;
+
+ @Autowired
+ private IAmsAssetLocationService amsAssetLocationService;
+
+ @Autowired
+ private ISysDeptService sysDeptService;
+
+ @RequiresPermissions("asset:return:view")
+ @GetMapping()
+ public String returnPage(ModelMap mmap)
+ {
+ mmap.put("warehouseList", selectEnabledWarehouseList());
+ mmap.put("deptList", selectNormalDeptList());
+ return prefix + "/return";
+ }
+
+ @RequiresPermissions("asset:return:list")
+ @PostMapping("/list")
+ @ResponseBody
+ public TableDataInfo list(AmsReturnOrder order)
+ {
+ startPage();
+ return getDataTable(amsReturnOrderService.selectAmsReturnOrderList(order));
+ }
+
+ /**
+ * 打开在用资产分页选择页。
+ *
+ * 业务意图:
+ * 提供一个资产筛选弹出层,使得用户可以查找并选择要退库的资产。
+ * 参数 `originWarehouseId` 和 `missingOriginOnly` 用于在单据保存了第一个资产后,
+ * 强力约束后续资产必须和第一个资产的“来源仓库类型”或“原仓库 ID”相匹配,实现单仓库分组校验前端控制。
+ *
+ */
+ @RequiresPermissions(value = { "asset:return:add", "asset:return:edit" }, logical = Logical.OR)
+ @GetMapping("/selectAsset")
+ public String selectAsset(@RequestParam(value = "orderId", required = false) Long orderId,
+ @RequestParam(value = "originWarehouseId", required = false) Long originWarehouseId,
+ @RequestParam(value = "missingOriginOnly", required = false) Boolean missingOriginOnly,
+ ModelMap mmap)
+ {
+ mmap.put("orderId", orderId);
+ mmap.put("originWarehouseId", originWarehouseId);
+ mmap.put("missingOriginOnly", missingOriginOnly);
+ mmap.put("deptList", selectNormalDeptList());
+ return prefix + "/selectAsset";
+ }
+
+ /**
+ * 查询未被其他有效退库单占用的在用资产。
+ *
+ * 业务意图与避坑:
+ * 1. 只有处于“在用(IN_USE)”状态的资产才允许被选择退库。
+ * 2. 必须排除当前已被其他“草稿”或“待确认”退库单占用的资产,以防在审批流转中产生资产被重复退库的并发漏洞。
+ * 3. 若 `orderId` 不为空,则查询时应把当前退库单已经占有的资产剔除排除条件,防止自冲突。
+ *
+ */
+ @RequiresPermissions(value = { "asset:return:add", "asset:return:edit" }, logical = Logical.OR)
+ @PostMapping("/availableAssetList")
+ @ResponseBody
+ public TableDataInfo availableAssetList(AmsAsset asset,
+ @RequestParam(value = "orderId", required = false) Long orderId,
+ @RequestParam(value = "originWarehouseId", required = false) Long originWarehouseId,
+ @RequestParam(value = "missingOriginOnly", required = false) Boolean missingOriginOnly)
+ {
+ startPage();
+ return getDataTable(amsReturnOrderService.selectAvailableReturnAssetList(asset, orderId,
+ originWarehouseId, missingOriginOnly));
+ }
+
+ @RequiresPermissions("asset:return:export")
+ @Log(title = "退库管理", businessType = BusinessType.EXPORT)
+ @PostMapping("/export")
+ @ResponseBody
+ public AjaxResult export(AmsReturnOrder order)
+ {
+ List list = amsReturnOrderService.selectAmsReturnOrderList(order);
+ return new ExcelUtil(AmsReturnOrder.class).exportExcel(list, "退库管理数据");
+ }
+
+ @RequiresPermissions("asset:return:view")
+ @GetMapping("/view/{orderId}")
+ public String view(@PathVariable("orderId") Long orderId, ModelMap mmap)
+ {
+ mmap.put("amsReturnOrder", amsReturnOrderService.selectAmsReturnOrderByOrderId(orderId));
+ return prefix + "/view";
+ }
+
+ @RequiresPermissions("asset:return:add")
+ @GetMapping("/add")
+ public String add(ModelMap mmap)
+ {
+ putReturnOptions(mmap);
+ return prefix + "/add";
+ }
+
+ @RequiresPermissions("asset:return:add")
+ @Log(title = "退库管理", businessType = BusinessType.INSERT)
+ @PostMapping("/add")
+ @ResponseBody
+ public AjaxResult addSave(AmsReturnOrder order)
+ {
+ order.setCreateBy(getLoginName());
+ return toAjax(amsReturnOrderService.insertAmsReturnOrder(order, getUserId(),
+ getSysUser().getUserName(), getSysUser().getDeptId()));
+ }
+
+ @RequiresPermissions("asset:return:edit")
+ @GetMapping("/edit/{orderId}")
+ public String edit(@PathVariable("orderId") Long orderId, ModelMap mmap)
+ {
+ mmap.put("amsReturnOrder", amsReturnOrderService.selectAmsReturnOrderByOrderId(orderId));
+ putReturnOptions(mmap);
+ return prefix + "/edit";
+ }
+
+ @RequiresPermissions("asset:return:edit")
+ @Log(title = "退库管理", businessType = BusinessType.UPDATE)
+ @PostMapping("/edit")
+ @ResponseBody
+ public AjaxResult editSave(AmsReturnOrder order)
+ {
+ order.setUpdateBy(getLoginName());
+ return toAjax(amsReturnOrderService.updateAmsReturnOrder(order));
+ }
+
+ /**
+ * 提交退库单。
+ *
+ * 业务意图:
+ * 将单据由“草稿(DRAFT)”状态提交为“待确认(PENDING_CONFIRM)”。
+ * 在提交前,后台会校验单据中所有资产的最新状态与使用归属。
+ *
+ */
+ @RequiresPermissions("asset:return:submit")
+ @Log(title = "退库管理", businessType = BusinessType.UPDATE)
+ @PostMapping("/submit/{orderId}")
+ @ResponseBody
+ public AjaxResult submit(@PathVariable("orderId") Long orderId)
+ {
+ return toAjax(amsReturnOrderService.submitReturn(orderId, getLoginName()));
+ }
+
+ /**
+ * 确认退库。
+ *
+ * 业务意图与避坑:
+ * 仓管员在实物清点无误后点击确认。单据状态由“待确认(PENDING_CONFIRM)”转为“已退库(RETURNED)”。
+ * 此动作会触发核心台账变化:资产的使用部门与使用人被清空,仓库与存放位置更新为退库后的目标仓位,并向资产履历中写入变动历史。
+ * 确认人及确认时间被作为最终审核凭证固化到单据主表中。
+ *
+ */
+ @RequiresPermissions("asset:return:confirm")
+ @Log(title = "退库管理", businessType = BusinessType.UPDATE)
+ @PostMapping("/confirm/{orderId}")
+ @ResponseBody
+ public AjaxResult confirm(@PathVariable("orderId") Long orderId)
+ {
+ return toAjax(amsReturnOrderService.confirmReturn(orderId, getUserId(),
+ getSysUser().getUserName(), getLoginName()));
+ }
+
+ @RequiresPermissions("asset:return:remove")
+ @Log(title = "退库管理", businessType = BusinessType.DELETE)
+ @PostMapping("/remove")
+ @ResponseBody
+ public AjaxResult remove(String ids)
+ {
+ return toAjax(amsReturnOrderService.deleteAmsReturnOrderByOrderIds(ids));
+ }
+
+ private void putReturnOptions(ModelMap mmap)
+ {
+ mmap.put("warehouseList", selectEnabledWarehouseList());
+ AmsAssetLocation location = new AmsAssetLocation();
+ location.setEnabled(ENABLED_YES);
+ mmap.put("locationList", amsAssetLocationService.selectAmsAssetLocationList(location));
+ }
+
+ private List selectEnabledWarehouseList()
+ {
+ AmsWarehouse warehouse = new AmsWarehouse();
+ warehouse.setEnabled(ENABLED_YES);
+ return amsWarehouseService.selectAmsWarehouseList(warehouse);
+ }
+
+ private List selectNormalDeptList()
+ {
+ SysDept dept = new SysDept();
+ dept.setStatus(UserConstants.DEPT_NORMAL);
+ return sysDeptService.selectDeptList(dept);
+ }
+}
diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsReturnAssetCandidate.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsReturnAssetCandidate.java
new file mode 100644
index 0000000..831dd18
--- /dev/null
+++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsReturnAssetCandidate.java
@@ -0,0 +1,184 @@
+package com.ruoyi.asset.domain;
+
+/**
+ * 可退库资产候选对象。
+ *
+ * 资产处于在用状态时当前仓位为空,因此退库默认仓位必须从最近一次已完成领用明细中读取。
+ *
+ *
+ * @author Yangk
+ */
+public class AmsReturnAssetCandidate extends AmsAsset
+{
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 最近一次已完成领用的源单据ID。
+ * 用于退库单回溯领用源头,并在业务逻辑中作为防呆比对的基础。
+ */
+ private Long sourceReceiveOrderId;
+
+ /**
+ * 最近一次已完成领用的源单据明细项ID。
+ * 在退库时,通过此明细ID定位具体的领用资产记录,用以核对当时被领用的历史原仓位。
+ */
+ private Long sourceReceiveItemId;
+
+ /**
+ * 最近一次已完成领用时的原仓库ID。
+ * 当资产处于“在用 (IN_USE)”状态时,其台账表上的当前仓库和位置字段通常为空。
+ * 因此,必须通过最近一次完成的领用单明细(当时记为 `before_warehouse_id`)来找回最初领出它的源头仓库。
+ */
+ private Long originWarehouseId;
+
+ /** 最近一次已完成领用时的原仓库编码 */
+ private String originWarehouseCode;
+
+ /** 最近一次已完成领用时的原仓库名称 */
+ private String originWarehouseName;
+
+ /**
+ * 最近一次已完成领用时的原仓库是否启用(Y表示启用,N表示停用)。
+ * 在退库逻辑中,如果原仓库已被停用,该资产将无法走常规的原仓库原路退回校验。
+ */
+ private String originWarehouseEnabled;
+
+ /**
+ * 最近一次已完成领用时的原资产位置ID。
+ * 用以协助定位资产退库时应当默认回退的位置节点。
+ */
+ private Long originLocationId;
+
+ /** 最近一次已完成领用时的原资产位置编码 */
+ private String originLocationCode;
+
+ /** 最近一次已完成领用时的原资产位置名称 */
+ private String originLocationName;
+
+ /** 最近一次已完成领用时的原资产位置是否启用(Y表示启用,N表示停用) */
+ private String originLocationEnabled;
+
+ public Long getSourceReceiveOrderId()
+ {
+ return sourceReceiveOrderId;
+ }
+
+ public void setSourceReceiveOrderId(Long sourceReceiveOrderId)
+ {
+ this.sourceReceiveOrderId = sourceReceiveOrderId;
+ }
+
+ public Long getSourceReceiveItemId()
+ {
+ return sourceReceiveItemId;
+ }
+
+ public void setSourceReceiveItemId(Long sourceReceiveItemId)
+ {
+ this.sourceReceiveItemId = sourceReceiveItemId;
+ }
+
+ public Long getOriginWarehouseId()
+ {
+ return originWarehouseId;
+ }
+
+ public void setOriginWarehouseId(Long originWarehouseId)
+ {
+ this.originWarehouseId = originWarehouseId;
+ }
+
+ public String getOriginWarehouseCode()
+ {
+ return originWarehouseCode;
+ }
+
+ public void setOriginWarehouseCode(String originWarehouseCode)
+ {
+ this.originWarehouseCode = originWarehouseCode;
+ }
+
+ public String getOriginWarehouseName()
+ {
+ return originWarehouseName;
+ }
+
+ public void setOriginWarehouseName(String originWarehouseName)
+ {
+ this.originWarehouseName = originWarehouseName;
+ }
+
+ public String getOriginWarehouseEnabled()
+ {
+ return originWarehouseEnabled;
+ }
+
+ public void setOriginWarehouseEnabled(String originWarehouseEnabled)
+ {
+ this.originWarehouseEnabled = originWarehouseEnabled;
+ }
+
+ public Long getOriginLocationId()
+ {
+ return originLocationId;
+ }
+
+ public void setOriginLocationId(Long originLocationId)
+ {
+ this.originLocationId = originLocationId;
+ }
+
+ public String getOriginLocationCode()
+ {
+ return originLocationCode;
+ }
+
+ public void setOriginLocationCode(String originLocationCode)
+ {
+ this.originLocationCode = originLocationCode;
+ }
+
+ public String getOriginLocationName()
+ {
+ return originLocationName;
+ }
+
+ public void setOriginLocationName(String originLocationName)
+ {
+ this.originLocationName = originLocationName;
+ }
+
+ public String getOriginLocationEnabled()
+ {
+ return originLocationEnabled;
+ }
+
+ public void setOriginLocationEnabled(String originLocationEnabled)
+ {
+ this.originLocationEnabled = originLocationEnabled;
+ }
+
+ /**
+ * 判断资产是否有明确、可追溯的领用来源仓位。
+ *
+ * 只有同时存在领用时的原仓库和原位置,才被认为是一笔具有完整可追溯链条的在用资产。
+ * 否则,系统将在退库单校验中将其归类为“无明确来源资产”。
+ *
+ */
+ public boolean isOriginTraceable()
+ {
+ return originWarehouseId != null && originLocationId != null;
+ }
+
+ /**
+ * 校验资产的源领用仓位是否依然可用。
+ *
+ * 在进行原路退回操作时,除来源可追溯外,还需确保当时的仓库和位置在当前时间点均处于启用("Y")状态。
+ * 若已被停用,系统应提示或拒绝执行默认的原路回填。
+ *
+ */
+ public boolean isOriginEnabled()
+ {
+ return isOriginTraceable() && "Y".equals(originWarehouseEnabled) && "Y".equals(originLocationEnabled);
+ }
+}
diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsReturnOrder.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsReturnOrder.java
new file mode 100644
index 0000000..ee29f75
--- /dev/null
+++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsReturnOrder.java
@@ -0,0 +1,257 @@
+package com.ruoyi.asset.domain;
+
+import java.util.List;
+import java.util.Date;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.common.core.domain.BaseEntity;
+
+/**
+ * 退库管理对象 ams_return_order
+ *
+ * @author Yangk
+ * @date 2026-06-15
+ */
+public class AmsReturnOrder extends BaseEntity
+{
+ private static final long serialVersionUID = 1L;
+
+ /** 单据ID */
+ private Long orderId;
+
+ /** 退库单号 */
+ @Excel(name = "退库单号")
+ private String returnNo;
+
+ /** 申请人ID */
+ @Excel(name = "申请人ID")
+ private Long applicantId;
+
+ /** 申请人名称快照 */
+ @Excel(name = "申请人名称快照")
+ private String applicantName;
+
+ /** 申请部门ID */
+ @Excel(name = "申请部门ID")
+ private Long applyDeptId;
+
+ /** 申请部门名称快照 */
+ @Excel(name = "申请部门名称快照")
+ private String applyDeptName;
+
+ /** 接收仓库ID */
+ @Excel(name = "接收仓库ID")
+ private Long receiveWarehouseId;
+
+ /** 接收仓库编码快照 */
+ @Excel(name = "接收仓库编码快照")
+ private String receiveWarehouseCode;
+
+ /** 接收仓库名称快照 */
+ @Excel(name = "接收仓库名称快照")
+ private String receiveWarehouseName;
+
+ /** 确认人ID */
+ @Excel(name = "确认人ID")
+ private Long confirmUserId;
+
+ /** 确认人名称快照 */
+ @Excel(name = "确认人名称快照")
+ private String confirmUserName;
+
+ /** 确认时间 */
+ @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+ @Excel(name = "确认时间", width = 30, dateFormat = "yyyy-MM-dd")
+ private Date confirmTime;
+
+ /** 单据状态 */
+ @Excel(name = "单据状态")
+ private String orderStatus;
+
+ /** 删除标志:0存在,1删除 */
+ private String delFlag;
+
+ /**
+ * 退库单关联的资产明细项列表。
+ * 每一个明细项记录了单一资产退库前的领用部门、领用人、以及退回后的仓库、位置快照。
+ */
+ private List amsReturnOrderItemList;
+
+ public void setOrderId(Long orderId)
+ {
+ this.orderId = orderId;
+ }
+
+ public Long getOrderId()
+ {
+ return orderId;
+ }
+
+ public void setReturnNo(String returnNo)
+ {
+ this.returnNo = returnNo;
+ }
+
+ public String getReturnNo()
+ {
+ return returnNo;
+ }
+
+ public void setApplicantId(Long applicantId)
+ {
+ this.applicantId = applicantId;
+ }
+
+ public Long getApplicantId()
+ {
+ return applicantId;
+ }
+
+ public void setApplicantName(String applicantName)
+ {
+ this.applicantName = applicantName;
+ }
+
+ public String getApplicantName()
+ {
+ return applicantName;
+ }
+
+ public void setApplyDeptId(Long applyDeptId)
+ {
+ this.applyDeptId = applyDeptId;
+ }
+
+ public Long getApplyDeptId()
+ {
+ return applyDeptId;
+ }
+
+ public void setApplyDeptName(String applyDeptName)
+ {
+ this.applyDeptName = applyDeptName;
+ }
+
+ public String getApplyDeptName()
+ {
+ return applyDeptName;
+ }
+
+ public void setReceiveWarehouseId(Long receiveWarehouseId)
+ {
+ this.receiveWarehouseId = receiveWarehouseId;
+ }
+
+ public Long getReceiveWarehouseId()
+ {
+ return receiveWarehouseId;
+ }
+
+ public void setReceiveWarehouseCode(String receiveWarehouseCode)
+ {
+ this.receiveWarehouseCode = receiveWarehouseCode;
+ }
+
+ public String getReceiveWarehouseCode()
+ {
+ return receiveWarehouseCode;
+ }
+
+ public void setReceiveWarehouseName(String receiveWarehouseName)
+ {
+ this.receiveWarehouseName = receiveWarehouseName;
+ }
+
+ public String getReceiveWarehouseName()
+ {
+ return receiveWarehouseName;
+ }
+
+ public void setConfirmUserId(Long confirmUserId)
+ {
+ this.confirmUserId = confirmUserId;
+ }
+
+ public Long getConfirmUserId()
+ {
+ return confirmUserId;
+ }
+
+ public void setConfirmUserName(String confirmUserName)
+ {
+ this.confirmUserName = confirmUserName;
+ }
+
+ public String getConfirmUserName()
+ {
+ return confirmUserName;
+ }
+
+ public void setConfirmTime(Date confirmTime)
+ {
+ this.confirmTime = confirmTime;
+ }
+
+ public Date getConfirmTime()
+ {
+ return confirmTime;
+ }
+
+ public void setOrderStatus(String orderStatus)
+ {
+ this.orderStatus = orderStatus;
+ }
+
+ public String getOrderStatus()
+ {
+ return orderStatus;
+ }
+
+ public void setDelFlag(String delFlag)
+ {
+ this.delFlag = delFlag;
+ }
+
+ public String getDelFlag()
+ {
+ return delFlag;
+ }
+
+ public List getAmsReturnOrderItemList()
+ {
+ return amsReturnOrderItemList;
+ }
+
+ public void setAmsReturnOrderItemList(List amsReturnOrderItemList)
+ {
+ this.amsReturnOrderItemList = amsReturnOrderItemList;
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
+ .append("orderId", getOrderId())
+ .append("returnNo", getReturnNo())
+ .append("applicantId", getApplicantId())
+ .append("applicantName", getApplicantName())
+ .append("applyDeptId", getApplyDeptId())
+ .append("applyDeptName", getApplyDeptName())
+ .append("receiveWarehouseId", getReceiveWarehouseId())
+ .append("receiveWarehouseCode", getReceiveWarehouseCode())
+ .append("receiveWarehouseName", getReceiveWarehouseName())
+ .append("confirmUserId", getConfirmUserId())
+ .append("confirmUserName", getConfirmUserName())
+ .append("confirmTime", getConfirmTime())
+ .append("orderStatus", getOrderStatus())
+ .append("createBy", getCreateBy())
+ .append("createTime", getCreateTime())
+ .append("updateBy", getUpdateBy())
+ .append("updateTime", getUpdateTime())
+ .append("remark", getRemark())
+ .append("delFlag", getDelFlag())
+ .append("amsReturnOrderItemList", getAmsReturnOrderItemList())
+ .toString();
+ }
+}
diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsReturnOrderItem.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsReturnOrderItem.java
new file mode 100644
index 0000000..aeb7a88
--- /dev/null
+++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsReturnOrderItem.java
@@ -0,0 +1,479 @@
+package com.ruoyi.asset.domain;
+
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.common.core.domain.BaseEntity;
+
+/**
+ * 退库单明细对象 ams_return_order_item
+ *
+ * @author Yangk
+ * @date 2026-06-15
+ */
+public class AmsReturnOrderItem extends BaseEntity
+{
+ private static final long serialVersionUID = 1L;
+
+ /** 明细ID */
+ private Long itemId;
+
+ /** 退库单ID */
+ @Excel(name = "退库单ID")
+ private Long orderId;
+
+ /** 退库单号快照 */
+ @Excel(name = "退库单号快照")
+ private String returnNo;
+
+ /** 资产ID */
+ @Excel(name = "资产ID")
+ private Long assetId;
+
+ /** 资产编码快照 */
+ @Excel(name = "资产编码快照")
+ private String assetCode;
+
+ /** 资产名称快照 */
+ @Excel(name = "资产名称快照")
+ private String assetName;
+
+ /** 资产类别ID快照 */
+ @Excel(name = "资产类别ID快照")
+ private Long categoryId;
+
+ /** 类别编码快照 */
+ @Excel(name = "类别编码快照")
+ private String categoryCode;
+
+ /** 类别名称快照 */
+ @Excel(name = "类别名称快照")
+ private String categoryName;
+
+ /** 规格型号快照 */
+ @Excel(name = "规格型号快照")
+ private String specModel;
+
+ /** 品牌快照 */
+ @Excel(name = "品牌快照")
+ private String brand;
+
+ /**
+ * 资产当前状态。
+ * 仅用于退库列表/详情页面的动态关联展示,不进行持久化存储。
+ */
+ private String assetStatus;
+
+ /**
+ * 最近一次已完成领用单对应的源仓库ID。
+ * 此字段为非持久化字段,仅在退库申请页面中用于:
+ * 1. 默认带出资产退回的建议仓位;
+ * 2. 作为后端单据“同一退库单只允许退回原同仓库来源资产”的分组校验依据。
+ */
+ private Long originWarehouseId;
+
+ /** 最近一次已完成领用的原仓库编码(仅用于前端展示与比对,不持久化) */
+ private String originWarehouseCode;
+
+ /** 最近一次已完成领用的原仓库名称(仅用于前端展示与比对,不持久化) */
+ private String originWarehouseName;
+
+ /** 最近一次已完成领用的原仓库启用状态(仅用于页面原路退库合法性校验,不持久化) */
+ private String originWarehouseEnabled;
+
+ /**
+ * 最近一次已完成领用单对应的源位置ID。
+ * 用以协助实现原路退库时对位置级别的默认回填与可用性审查。
+ */
+ private Long originLocationId;
+
+ /** 最近一次已完成领用的原位置编码(仅用于前端展示,不持久化) */
+ private String originLocationCode;
+
+ /** 最近一次已完成领用的原位置名称(仅用于前端展示,不持久化) */
+ private String originLocationName;
+
+ /** 最近一次已完成领用的原位置启用状态(仅用于页面原路退库合法性校验,不持久化) */
+ private String originLocationEnabled;
+
+ /**
+ * 退库前使用人ID。
+ * 此字段是关键的历史归属快照。在草稿保存时写入,并在单据提交与确认退库时,
+ * 用来与资产台账(`ams_asset`)的实时使用人字段进行比对。若不一致,说明该资产已被转借或另作他用,必须阻断流程。
+ */
+ @Excel(name = "退库前使用人ID")
+ private Long beforeUserId;
+
+ /** 退库前使用人名称快照。记录资产退库前那一刻的责任人名称以备审计 */
+ @Excel(name = "退库前使用人名称快照")
+ private String beforeUserName;
+
+ /**
+ * 退库前部门ID。
+ * 同样作为归属历史快照,用于提交/确认时的防并发防脏写安全校验。
+ */
+ @Excel(name = "退库前部门ID")
+ private Long beforeDeptId;
+
+ /** 退库前部门名称快照。记录资产退库前那一刻的所属部门以备审计 */
+ @Excel(name = "退库前部门名称快照")
+ private String beforeDeptName;
+
+ /**
+ * 退库后入库的仓库ID。
+ * 实物退库完成确认后,资产当前的归属仓库将被变更为此ID,且资产状态回滚为“在库(IN_STOCK)”。
+ */
+ @Excel(name = "退库后仓库ID")
+ private Long afterWarehouseId;
+
+ /** 退库后仓库编码快照 */
+ @Excel(name = "退库后仓库编码快照")
+ private String afterWarehouseCode;
+
+ /** 退库后仓库名称快照 */
+ @Excel(name = "退库后仓库名称快照")
+ private String afterWarehouseName;
+
+ /**
+ * 退库后入库的资产具体存放位置ID。
+ * 必须隶属于主表所选的接收仓库,否则会在保存/提交时校验失败。
+ */
+ @Excel(name = "退库后位置ID")
+ private Long afterLocationId;
+
+ /** 退库后位置编码快照 */
+ @Excel(name = "退库后位置编码快照")
+ private String afterLocationCode;
+
+ /** 退库后位置名称快照 */
+ @Excel(name = "退库后位置名称快照")
+ private String afterLocationName;
+
+ /** 删除标志:0存在,1删除 */
+ private String delFlag;
+
+ public void setItemId(Long itemId)
+ {
+ this.itemId = itemId;
+ }
+
+ public Long getItemId()
+ {
+ return itemId;
+ }
+ public void setOrderId(Long orderId)
+ {
+ this.orderId = orderId;
+ }
+
+ public Long getOrderId()
+ {
+ return orderId;
+ }
+ public void setReturnNo(String returnNo)
+ {
+ this.returnNo = returnNo;
+ }
+
+ public String getReturnNo()
+ {
+ return returnNo;
+ }
+ public void setAssetId(Long assetId)
+ {
+ this.assetId = assetId;
+ }
+
+ public Long getAssetId()
+ {
+ return assetId;
+ }
+ public void setAssetCode(String assetCode)
+ {
+ this.assetCode = assetCode;
+ }
+
+ public String getAssetCode()
+ {
+ return assetCode;
+ }
+ public void setAssetName(String assetName)
+ {
+ this.assetName = assetName;
+ }
+
+ public String getAssetName()
+ {
+ return assetName;
+ }
+ public void setCategoryId(Long categoryId)
+ {
+ this.categoryId = categoryId;
+ }
+
+ public Long getCategoryId()
+ {
+ return categoryId;
+ }
+ public void setCategoryCode(String categoryCode)
+ {
+ this.categoryCode = categoryCode;
+ }
+
+ public String getCategoryCode()
+ {
+ return categoryCode;
+ }
+ public void setCategoryName(String categoryName)
+ {
+ this.categoryName = categoryName;
+ }
+
+ public String getCategoryName()
+ {
+ return categoryName;
+ }
+ public void setSpecModel(String specModel)
+ {
+ this.specModel = specModel;
+ }
+
+ public String getSpecModel()
+ {
+ return specModel;
+ }
+ public void setBrand(String brand)
+ {
+ this.brand = brand;
+ }
+
+ public String getBrand()
+ {
+ return brand;
+ }
+ public void setAssetStatus(String assetStatus)
+ {
+ this.assetStatus = assetStatus;
+ }
+
+ public String getAssetStatus()
+ {
+ return assetStatus;
+ }
+ public Long getOriginWarehouseId()
+ {
+ return originWarehouseId;
+ }
+
+ public void setOriginWarehouseId(Long originWarehouseId)
+ {
+ this.originWarehouseId = originWarehouseId;
+ }
+
+ public String getOriginWarehouseCode()
+ {
+ return originWarehouseCode;
+ }
+
+ public void setOriginWarehouseCode(String originWarehouseCode)
+ {
+ this.originWarehouseCode = originWarehouseCode;
+ }
+
+ public String getOriginWarehouseName()
+ {
+ return originWarehouseName;
+ }
+
+ public void setOriginWarehouseName(String originWarehouseName)
+ {
+ this.originWarehouseName = originWarehouseName;
+ }
+
+ public String getOriginWarehouseEnabled()
+ {
+ return originWarehouseEnabled;
+ }
+
+ public void setOriginWarehouseEnabled(String originWarehouseEnabled)
+ {
+ this.originWarehouseEnabled = originWarehouseEnabled;
+ }
+
+ public Long getOriginLocationId()
+ {
+ return originLocationId;
+ }
+
+ public void setOriginLocationId(Long originLocationId)
+ {
+ this.originLocationId = originLocationId;
+ }
+
+ public String getOriginLocationCode()
+ {
+ return originLocationCode;
+ }
+
+ public void setOriginLocationCode(String originLocationCode)
+ {
+ this.originLocationCode = originLocationCode;
+ }
+
+ public String getOriginLocationName()
+ {
+ return originLocationName;
+ }
+
+ public void setOriginLocationName(String originLocationName)
+ {
+ this.originLocationName = originLocationName;
+ }
+
+ public String getOriginLocationEnabled()
+ {
+ return originLocationEnabled;
+ }
+
+ public void setOriginLocationEnabled(String originLocationEnabled)
+ {
+ this.originLocationEnabled = originLocationEnabled;
+ }
+ public void setBeforeUserId(Long beforeUserId)
+ {
+ this.beforeUserId = beforeUserId;
+ }
+
+ public Long getBeforeUserId()
+ {
+ return beforeUserId;
+ }
+ public void setBeforeUserName(String beforeUserName)
+ {
+ this.beforeUserName = beforeUserName;
+ }
+
+ public String getBeforeUserName()
+ {
+ return beforeUserName;
+ }
+ public void setBeforeDeptId(Long beforeDeptId)
+ {
+ this.beforeDeptId = beforeDeptId;
+ }
+
+ public Long getBeforeDeptId()
+ {
+ return beforeDeptId;
+ }
+ public void setBeforeDeptName(String beforeDeptName)
+ {
+ this.beforeDeptName = beforeDeptName;
+ }
+
+ public String getBeforeDeptName()
+ {
+ return beforeDeptName;
+ }
+ public void setAfterWarehouseId(Long afterWarehouseId)
+ {
+ this.afterWarehouseId = afterWarehouseId;
+ }
+
+ public Long getAfterWarehouseId()
+ {
+ return afterWarehouseId;
+ }
+ public void setAfterWarehouseCode(String afterWarehouseCode)
+ {
+ this.afterWarehouseCode = afterWarehouseCode;
+ }
+
+ public String getAfterWarehouseCode()
+ {
+ return afterWarehouseCode;
+ }
+ public void setAfterWarehouseName(String afterWarehouseName)
+ {
+ this.afterWarehouseName = afterWarehouseName;
+ }
+
+ public String getAfterWarehouseName()
+ {
+ return afterWarehouseName;
+ }
+ public void setAfterLocationId(Long afterLocationId)
+ {
+ this.afterLocationId = afterLocationId;
+ }
+
+ public Long getAfterLocationId()
+ {
+ return afterLocationId;
+ }
+ public void setAfterLocationCode(String afterLocationCode)
+ {
+ this.afterLocationCode = afterLocationCode;
+ }
+
+ public String getAfterLocationCode()
+ {
+ return afterLocationCode;
+ }
+ public void setAfterLocationName(String afterLocationName)
+ {
+ this.afterLocationName = afterLocationName;
+ }
+
+ public String getAfterLocationName()
+ {
+ return afterLocationName;
+ }
+ public void setDelFlag(String delFlag)
+ {
+ this.delFlag = delFlag;
+ }
+
+ public String getDelFlag()
+ {
+ return delFlag;
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
+ .append("itemId", getItemId())
+ .append("orderId", getOrderId())
+ .append("returnNo", getReturnNo())
+ .append("assetId", getAssetId())
+ .append("assetCode", getAssetCode())
+ .append("assetName", getAssetName())
+ .append("categoryId", getCategoryId())
+ .append("categoryCode", getCategoryCode())
+ .append("categoryName", getCategoryName())
+ .append("specModel", getSpecModel())
+ .append("brand", getBrand())
+ .append("assetStatus", getAssetStatus())
+ .append("originWarehouseId", getOriginWarehouseId())
+ .append("originWarehouseName", getOriginWarehouseName())
+ .append("originLocationId", getOriginLocationId())
+ .append("originLocationName", getOriginLocationName())
+ .append("beforeUserId", getBeforeUserId())
+ .append("beforeUserName", getBeforeUserName())
+ .append("beforeDeptId", getBeforeDeptId())
+ .append("beforeDeptName", getBeforeDeptName())
+ .append("afterWarehouseId", getAfterWarehouseId())
+ .append("afterWarehouseCode", getAfterWarehouseCode())
+ .append("afterWarehouseName", getAfterWarehouseName())
+ .append("afterLocationId", getAfterLocationId())
+ .append("afterLocationCode", getAfterLocationCode())
+ .append("afterLocationName", getAfterLocationName())
+ .append("createBy", getCreateBy())
+ .append("createTime", getCreateTime())
+ .append("updateBy", getUpdateBy())
+ .append("updateTime", getUpdateTime())
+ .append("remark", getRemark())
+ .append("delFlag", getDelFlag())
+ .toString();
+ }
+}
diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/mapper/AmsReturnOrderMapper.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/mapper/AmsReturnOrderMapper.java
new file mode 100644
index 0000000..398a4cc
--- /dev/null
+++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/mapper/AmsReturnOrderMapper.java
@@ -0,0 +1,78 @@
+package com.ruoyi.asset.mapper;
+
+import java.util.List;
+import com.ruoyi.asset.domain.AmsAsset;
+import com.ruoyi.asset.domain.AmsReturnAssetCandidate;
+import com.ruoyi.asset.domain.AmsReturnOrder;
+import com.ruoyi.asset.domain.AmsReturnOrderItem;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 退库管理Mapper接口
+ *
+ * @author Yangk
+ */
+public interface AmsReturnOrderMapper
+{
+ public AmsReturnOrder selectAmsReturnOrderByOrderId(Long orderId);
+
+ /**
+ * 查询退库单并对其所在的数据库记录行加排他锁(FOR UPDATE)。
+ *
+ * 业务保障:
+ * 用于确保在进行单据提交(DRAFT -> PENDING_CONFIRM)或确认退库(PENDING_CONFIRM -> RETURNED)等状态操作时,
+ * 两个并发的线程不会同时修改同一条单据状态,从而杜绝多线程下的事务竞态和脏数据。
+ *
+ */
+ public AmsReturnOrder selectAmsReturnOrderByOrderIdForUpdate(Long orderId);
+
+ public List selectAmsReturnOrderList(AmsReturnOrder amsReturnOrder);
+
+ /**
+ * 查询未被其他有效退库单占用的可退库(在用状态)资产候选列表。
+ *
+ * @param amsAsset 资产基础筛选信息
+ * @param currentOrderId 当前编辑的退库单ID(若存在,查询应包括当前单据已包含的资产,避免自冲突)
+ * @param originWarehouseId 限制特定的源仓库ID(一单一仓库规则)
+ * @param missingOriginOnly 是否仅筛选缺少领用来源的资产
+ * @param inUseStatus 资产正常的“在用”状态常量
+ * @param draftStatus 退库单的“草稿”状态常量(防占用检测用)
+ * @param pendingStatus 退库单的“待确认”状态常量(防占用检测用)
+ */
+ public List selectAvailableReturnAssetList(@Param("asset") AmsAsset amsAsset,
+ @Param("currentOrderId") Long currentOrderId, @Param("originWarehouseId") Long originWarehouseId,
+ @Param("missingOriginOnly") Boolean missingOriginOnly, @Param("inUseStatus") String inUseStatus,
+ @Param("draftStatus") String draftStatus, @Param("pendingStatus") String pendingStatus);
+
+ /**
+ * 查询资产最近一次已完成领用的原仓位快照。
+ * 从领用历史表中按确认时间降序关联提取。
+ */
+ public AmsReturnAssetCandidate selectReturnAssetCandidateByAssetId(Long assetId);
+
+ /**
+ * 统计指定资产在除当前单据外的其他有效退库单(草稿、待确认)中的占用数量。
+ * 用以在保存、提交、确认时进行排他性资产占用防呆验证。
+ */
+ public int countOtherActiveReturnOrderByAssetId(@Param("assetId") Long assetId,
+ @Param("currentOrderId") Long currentOrderId, @Param("draftStatus") String draftStatus,
+ @Param("pendingStatus") String pendingStatus);
+
+ public int insertAmsReturnOrder(AmsReturnOrder amsReturnOrder);
+
+ public int updateAmsReturnOrder(AmsReturnOrder amsReturnOrder);
+
+ public int submitAmsReturnOrder(AmsReturnOrder amsReturnOrder);
+
+ public int confirmAmsReturnOrder(AmsReturnOrder amsReturnOrder);
+
+ public int deleteAmsReturnOrderByOrderId(Long orderId);
+
+ public int deleteAmsReturnOrderByOrderIds(String[] orderIds);
+
+ public int deleteAmsReturnOrderItemByOrderIds(String[] orderIds);
+
+ public int batchAmsReturnOrderItem(List amsReturnOrderItemList);
+
+ public int deleteAmsReturnOrderItemByOrderId(Long orderId);
+}
diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/service/IAmsReturnOrderService.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/service/IAmsReturnOrderService.java
new file mode 100644
index 0000000..21beeeb
--- /dev/null
+++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/service/IAmsReturnOrderService.java
@@ -0,0 +1,38 @@
+package com.ruoyi.asset.service;
+
+import java.util.List;
+import com.ruoyi.asset.domain.AmsAsset;
+import com.ruoyi.asset.domain.AmsReturnAssetCandidate;
+import com.ruoyi.asset.domain.AmsReturnOrder;
+
+/**
+ * 退库管理Service接口
+ *
+ * @author Yangk
+ */
+public interface IAmsReturnOrderService
+{
+ public AmsReturnOrder selectAmsReturnOrderByOrderId(Long orderId);
+
+ public List selectAmsReturnOrderList(AmsReturnOrder amsReturnOrder);
+
+ /** 查询未被其他有效退库单占用的在用资产。 */
+ public List selectAvailableReturnAssetList(AmsAsset amsAsset, Long currentOrderId,
+ Long originWarehouseId, Boolean missingOriginOnly);
+
+ public int insertAmsReturnOrder(AmsReturnOrder amsReturnOrder, Long applicantId,
+ String applicantName, Long applyDeptId);
+
+ public int updateAmsReturnOrder(AmsReturnOrder amsReturnOrder);
+
+ /** 提交退库单(草稿 → 待确认)。 */
+ public int submitReturn(Long orderId, String operateLoginName);
+
+ /** 确认退库(待确认 → 已退库)。 */
+ public int confirmReturn(Long orderId, Long operateUserId, String operateUserName,
+ String operateLoginName);
+
+ public int deleteAmsReturnOrderByOrderIds(String orderIds);
+
+ public int deleteAmsReturnOrderByOrderId(Long orderId);
+}
diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/service/impl/AmsReturnOrderServiceImpl.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/service/impl/AmsReturnOrderServiceImpl.java
new file mode 100644
index 0000000..dd562ea
--- /dev/null
+++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/service/impl/AmsReturnOrderServiceImpl.java
@@ -0,0 +1,689 @@
+package com.ruoyi.asset.service.impl;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import com.ruoyi.asset.constant.AssetStatus;
+import com.ruoyi.asset.constant.ReturnOrderStatus;
+import com.ruoyi.asset.domain.AmsAsset;
+import com.ruoyi.asset.domain.AmsAssetLocation;
+import com.ruoyi.asset.domain.AmsReturnAssetCandidate;
+import com.ruoyi.asset.domain.AmsReturnOrder;
+import com.ruoyi.asset.domain.AmsReturnOrderItem;
+import com.ruoyi.asset.domain.AmsWarehouse;
+import com.ruoyi.asset.domain.AssetTransitionContext;
+import com.ruoyi.asset.mapper.AmsAssetMapper;
+import com.ruoyi.asset.mapper.AmsReturnOrderMapper;
+import com.ruoyi.asset.service.IAmsAssetLocationService;
+import com.ruoyi.asset.service.IAmsReturnOrderService;
+import com.ruoyi.asset.service.IAmsWarehouseService;
+import com.ruoyi.asset.service.IAssetStatusTransitionService;
+import com.ruoyi.common.constant.UserConstants;
+import com.ruoyi.common.core.domain.entity.SysDept;
+import com.ruoyi.common.core.domain.entity.SysUser;
+import com.ruoyi.common.core.text.Convert;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.service.ISysCodeRuleService;
+import com.ruoyi.system.service.ISysDeptService;
+import com.ruoyi.system.service.ISysUserService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 退库管理Service业务层处理
+ *
+ * 核心业务职责包括:
+ * 1. 资产状态及使用归属检验:防止非“在用”或已被其他有效退库单占用的资产被加入单据;
+ * 2. 退库单状态流转控制:严格遵守 草稿(DRAFT) -> 待确认(PENDING_CONFIRM) -> 已退库(RETURNED) 这一单向流转顺序;
+ * 3. 核心字段篡改预防:在更新单据时,涉及单据号及申请人/部门的快照字段强制复用原始数据;
+ * 4. 实物确认时的资产流转:成功确认退库后,将委托公共流转服务更新资产台账和记录资产履历,并锁定资产防止死锁。
+ *
+ *
+ * @author Yangk
+ */
+@Service
+public class AmsReturnOrderServiceImpl implements IAmsReturnOrderService
+{
+ /** 退库单在 sys_code_rule 中配置的自动编码规则标识 */
+ private static final String RETURN_ORDER_RULE = "RETURN_ORDER";
+
+ private static final String ENABLED_YES = "Y";
+
+ private static final String DEL_FLAG_NORMAL = "0";
+
+ @Autowired
+ private AmsReturnOrderMapper amsReturnOrderMapper;
+
+ @Autowired
+ private AmsAssetMapper amsAssetMapper;
+
+ @Autowired
+ private ISysCodeRuleService sysCodeRuleService;
+
+ @Autowired
+ private IAmsWarehouseService amsWarehouseService;
+
+ @Autowired
+ private IAmsAssetLocationService amsAssetLocationService;
+
+ @Autowired
+ private ISysDeptService sysDeptService;
+
+ @Autowired
+ private ISysUserService sysUserService;
+
+ @Autowired
+ private IAssetStatusTransitionService assetStatusTransitionService;
+
+ @Override
+ public AmsReturnOrder selectAmsReturnOrderByOrderId(Long orderId)
+ {
+ AmsReturnOrder order = amsReturnOrderMapper.selectAmsReturnOrderByOrderId(orderId);
+ fillOriginReferences(order);
+ return order;
+ }
+
+ @Override
+ public List selectAmsReturnOrderList(AmsReturnOrder order)
+ {
+ return amsReturnOrderMapper.selectAmsReturnOrderList(order);
+ }
+
+ @Override
+ public List selectAvailableReturnAssetList(AmsAsset asset, Long currentOrderId,
+ Long originWarehouseId, Boolean missingOriginOnly)
+ {
+ return amsReturnOrderMapper.selectAvailableReturnAssetList(asset, currentOrderId,
+ originWarehouseId, missingOriginOnly,
+ AssetStatus.IN_USE, ReturnOrderStatus.DRAFT, ReturnOrderStatus.PENDING_CONFIRM);
+ }
+
+ /**
+ * 保存退库草稿单。
+ *
+ * 业务意图与安全设计:
+ * 1. 申请人、申请部门信息进行合法性审查,并强制校验“申请人是否属于申请部门”,防范非法的业务提交;
+ * 2. 调用自动编码规则 `sysCodeRuleService.nextCode` 生成全局唯一的退库单号,该调用在事务内执行锁行;
+ * 3. 对单据中的每个资产明细项锁定并写入原始状态及当前使用归属的快照(作为后续确认时防脏写的版本基准)。
+ *
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int insertAmsReturnOrder(AmsReturnOrder order, Long applicantId, String applicantName,
+ Long applyDeptId)
+ {
+ validateOrderRequest(order);
+ fillApplicantSnapshots(order, applicantId, applicantName, applyDeptId);
+ order.setReturnNo(sysCodeRuleService.nextCode(RETURN_ORDER_RULE));
+ order.setOrderStatus(ReturnOrderStatus.DRAFT);
+ order.setConfirmUserId(null);
+ order.setConfirmUserName(null);
+ order.setConfirmTime(null);
+ order.setDelFlag(DEL_FLAG_NORMAL);
+ order.setCreateTime(DateUtils.getNowDate());
+ fillOrderSnapshots(order, null);
+
+ if (amsReturnOrderMapper.insertAmsReturnOrder(order) != 1 || StringUtils.isNull(order.getOrderId()))
+ {
+ throw new ServiceException("退库单保存失败");
+ }
+ insertReturnOrderItems(order);
+ return 1;
+ }
+
+ /**
+ * 更新退库草稿单。
+ *
+ * 业务意图与防篡改:
+ * 1. 只有处于“草稿(DRAFT)”状态的退库单才允许修改;
+ * 2. 防篡改设计:退库单号 `returnNo`、申请人/部门快照等字段具有受控特性,不允许通过前台直接修改。
+ * 故在此处强制使用数据库中现存的原始值覆盖传入的值,然后再持久化。
+ * 3. 修改时采取“先删明细再重新插入”的方式,确保保存后单据明细项与前台选择的状态完全一致。
+ *
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int updateAmsReturnOrder(AmsReturnOrder order)
+ {
+ if (StringUtils.isNull(order) || StringUtils.isNull(order.getOrderId()))
+ {
+ throw new ServiceException("退库单ID不能为空");
+ }
+ AmsReturnOrder current = requireOrderForUpdate(order.getOrderId(), ReturnOrderStatus.DRAFT,
+ "仅草稿退库单允许修改");
+ validateOrderRequest(order);
+
+ // 受控字段必须使用数据库当前值,防止普通编辑绕过单据状态和申请人快照。
+ order.setReturnNo(current.getReturnNo());
+ order.setApplicantId(current.getApplicantId());
+ order.setApplicantName(current.getApplicantName());
+ order.setApplyDeptId(current.getApplyDeptId());
+ order.setApplyDeptName(current.getApplyDeptName());
+ order.setOrderStatus(ReturnOrderStatus.DRAFT);
+ order.setConfirmUserId(null);
+ order.setConfirmUserName(null);
+ order.setConfirmTime(null);
+ order.setUpdateTime(DateUtils.getNowDate());
+ fillOrderSnapshots(order, order.getOrderId());
+
+ amsReturnOrderMapper.deleteAmsReturnOrderItemByOrderId(order.getOrderId());
+ insertReturnOrderItems(order);
+ if (amsReturnOrderMapper.updateAmsReturnOrder(order) != 1)
+ {
+ throw new ServiceException("退库单状态已变化,保存失败");
+ }
+ return 1;
+ }
+
+ /**
+ * 提交退库单。
+ *
+ * 业务意图与防呆设计:
+ * 1. 采用分布式/单机锁方案的前置机制:使用 `requireOrderForUpdate` 发送 `FOR UPDATE` 对退库单主行加排他锁,
+ * 并校验其状态必须为“草稿(DRAFT)”。防止其它并发事务重复提交或在审批流转时篡改数据;
+ * 2. 调用 `validateOrderReadyForFlow` 重新对该退库单关联的所有资产执行资产状态(必须是在用)与使用归属(不可在草稿期间改变)的强校验。
+ * 3. 在单据更新时校验影响行数,以确保更新的安全一致性。
+ *
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int submitReturn(Long orderId, String operateLoginName)
+ {
+ AmsReturnOrder order = requireOrderForUpdate(orderId, ReturnOrderStatus.DRAFT,
+ "仅草稿退库单允许提交");
+ validateLoginName(operateLoginName);
+ validateOrderReadyForFlow(order);
+
+ order.setOrderStatus(ReturnOrderStatus.PENDING_CONFIRM);
+ order.setUpdateBy(StringUtils.trim(operateLoginName));
+ order.setUpdateTime(DateUtils.getNowDate());
+ if (amsReturnOrderMapper.submitAmsReturnOrder(order) != 1)
+ {
+ throw new ServiceException("退库单状态已变化,提交失败");
+ }
+ return 1;
+ }
+
+ /**
+ * 确认退库。
+ *
+ * 业务意图与事务一致性设计:
+ * 1. 使用 `requireOrderForUpdate` 对单据加排他锁,验证当前状态为“待确认(PENDING_CONFIRM)”。
+ * 2. 调用 `validateOrderReadyForFlow` 进行最终状态锁定:该方法会锁住单据内所有资产并再次确认没有发生属性篡改。
+ * 3. 流转委托设计:退库核心逻辑(清空使用责任人及部门,回退到目标在库仓库/位置,生成资产履历)被统一收口在公共的资产生命周期流转服务
+ * `assetStatusTransitionService.confirmReturn` 中。
+ * 这样做能防止我们在不同业务模块重复手写资产状态修改 SQL 导致数据不一致(如遗漏履历记录或 RFID 绑定未处理)。
+ *
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int confirmReturn(Long orderId, Long operateUserId, String operateUserName,
+ String operateLoginName)
+ {
+ AmsReturnOrder order = requireOrderForUpdate(orderId, ReturnOrderStatus.PENDING_CONFIRM,
+ "仅待确认退库单允许确认");
+ validateOperator(operateUserId, operateUserName, "确认人");
+ validateLoginName(operateLoginName);
+ validateOrderReadyForFlow(order);
+
+ for (AmsReturnOrderItem item : sortedItems(order))
+ {
+ AssetTransitionContext context = new AssetTransitionContext();
+ context.setSourceOrderId(order.getOrderId());
+ context.setSourceOrderNo(order.getReturnNo());
+ context.setSourceItemId(item.getItemId());
+ context.setOperateUserId(operateUserId);
+ context.setOperateUserName(StringUtils.trim(operateUserName));
+ context.setOperateLoginName(StringUtils.trim(operateLoginName));
+ context.setChangeSummary("确认资产退库");
+ context.setRemark(order.getRemark());
+ assetStatusTransitionService.confirmReturn(item.getAssetId(), item.getAfterWarehouseId(),
+ item.getAfterLocationId(), context);
+ }
+
+ Date now = DateUtils.getNowDate();
+ order.setOrderStatus(ReturnOrderStatus.RETURNED);
+ order.setConfirmUserId(operateUserId);
+ order.setConfirmUserName(StringUtils.trim(operateUserName));
+ order.setConfirmTime(now);
+ order.setUpdateBy(StringUtils.trim(operateLoginName));
+ order.setUpdateTime(now);
+ if (amsReturnOrderMapper.confirmAmsReturnOrder(order) != 1)
+ {
+ throw new ServiceException("退库单状态已变化,确认退库失败");
+ }
+ return 1;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int deleteAmsReturnOrderByOrderIds(String orderIds)
+ {
+ Long[] sortedIds = Arrays.stream(Convert.toStrArray(orderIds))
+ .map(Long::valueOf).sorted().toArray(Long[]::new);
+ int rows = 0;
+ for (Long orderId : sortedIds)
+ {
+ AmsReturnOrder order = amsReturnOrderMapper.selectAmsReturnOrderByOrderIdForUpdate(orderId);
+ if (StringUtils.isNull(order))
+ {
+ continue;
+ }
+ requireDraft(order);
+ amsReturnOrderMapper.deleteAmsReturnOrderItemByOrderId(orderId);
+ rows += amsReturnOrderMapper.deleteAmsReturnOrderByOrderId(orderId);
+ }
+ return rows;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int deleteAmsReturnOrderByOrderId(Long orderId)
+ {
+ AmsReturnOrder order = amsReturnOrderMapper.selectAmsReturnOrderByOrderIdForUpdate(orderId);
+ if (StringUtils.isNull(order))
+ {
+ return 0;
+ }
+ requireDraft(order);
+ amsReturnOrderMapper.deleteAmsReturnOrderItemByOrderId(orderId);
+ return amsReturnOrderMapper.deleteAmsReturnOrderByOrderId(orderId);
+ }
+
+ private void validateOrderRequest(AmsReturnOrder order)
+ {
+ if (StringUtils.isNull(order))
+ {
+ throw new ServiceException("退库单不能为空");
+ }
+ if (order.getAmsReturnOrderItemList() == null || order.getAmsReturnOrderItemList().isEmpty())
+ {
+ throw new ServiceException("退库单明细不能为空");
+ }
+ validateLength(order.getRemark(), 500, "备注");
+ }
+
+ private void fillApplicantSnapshots(AmsReturnOrder order, Long applicantId, String applicantName,
+ Long applyDeptId)
+ {
+ validateOperator(applicantId, applicantName, "申请人");
+ SysUser applicant = requireNormalUser(applicantId, "申请人");
+ SysDept applyDept = requireNormalDept(applyDeptId, "申请部门");
+ if (!Objects.equals(applicant.getDeptId(), applyDept.getDeptId()))
+ {
+ throw new ServiceException("申请人不属于当前申请部门");
+ }
+ order.setApplicantId(applicantId);
+ order.setApplicantName(StringUtils.trim(applicantName));
+ order.setApplyDeptId(applyDept.getDeptId());
+ order.setApplyDeptName(applyDept.getDeptName());
+ }
+
+ /**
+ * 保存草稿时按资产ID升序锁定,回填退库前使用归属和退库后仓位快照。
+ */
+ private void fillOrderSnapshots(AmsReturnOrder order, Long currentOrderId)
+ {
+ AmsWarehouse warehouse = requireEnabledWarehouse(order.getReceiveWarehouseId());
+ order.setReceiveWarehouseCode(warehouse.getWarehouseCode());
+ order.setReceiveWarehouseName(warehouse.getWarehouseName());
+
+ Set assetIds = new HashSet<>();
+ ReturnOriginGroup originGroup = new ReturnOriginGroup();
+ List items = sortedItems(order);
+ order.setAmsReturnOrderItemList(items);
+ for (AmsReturnOrderItem item : items)
+ {
+ AmsAsset asset = lockReturnableAsset(item.getAssetId());
+ if (!assetIds.add(asset.getAssetId()))
+ {
+ throw new ServiceException("同一退库单不能重复选择资产");
+ }
+ validateNotOccupied(asset, currentOrderId);
+ originGroup.accept(asset, fillOriginReference(item));
+ fillAssetSnapshots(item, asset);
+ fillTargetSnapshots(item, asset, warehouse);
+ item.setReturnNo(order.getReturnNo());
+ item.setDelFlag(DEL_FLAG_NORMAL);
+ validateLength(item.getRemark(), 500, "明细备注");
+ }
+ }
+
+ /**
+ * 提交和确认前重新锁定资产,阻断草稿期间使用归属变化或资产被其他退库单占用。
+ */
+ private void validateOrderReadyForFlow(AmsReturnOrder order)
+ {
+ AmsWarehouse warehouse = requireEnabledWarehouse(order.getReceiveWarehouseId());
+ List items = sortedItems(order);
+ if (items.isEmpty())
+ {
+ throw new ServiceException("退库单明细不能为空");
+ }
+ Set assetIds = new HashSet<>();
+ ReturnOriginGroup originGroup = new ReturnOriginGroup();
+ for (AmsReturnOrderItem item : items)
+ {
+ AmsAsset asset = lockReturnableAsset(item.getAssetId());
+ if (!assetIds.add(asset.getAssetId()))
+ {
+ throw new ServiceException("同一退库单不能重复选择资产");
+ }
+ validateNotOccupied(asset, order.getOrderId());
+ originGroup.accept(asset, fillOriginReference(item));
+ validateBeforeSnapshot(item, asset);
+ fillTargetSnapshots(item, asset, warehouse);
+ }
+ }
+
+ private AmsAsset lockReturnableAsset(Long assetId)
+ {
+ if (StringUtils.isNull(assetId))
+ {
+ throw new ServiceException("退库资产不能为空");
+ }
+ AmsAsset asset = amsAssetMapper.selectAmsAssetByAssetIdForUpdate(assetId);
+ if (StringUtils.isNull(asset))
+ {
+ throw new ServiceException("退库资产不存在或已删除");
+ }
+ if (!StringUtils.equals(AssetStatus.IN_USE, asset.getAssetStatus()))
+ {
+ throw new ServiceException(StringUtils.format("资产【{}】当前状态不允许退库", asset.getAssetCode()));
+ }
+ if (StringUtils.isNull(asset.getUseDeptId()) || StringUtils.isNull(asset.getUseUserId())
+ || StringUtils.isEmpty(asset.getUseDeptName()) || StringUtils.isEmpty(asset.getUseUserName()))
+ {
+ throw new ServiceException(StringUtils.format("在用资产【{}】缺少当前使用部门或使用人", asset.getAssetCode()));
+ }
+ return asset;
+ }
+
+ private void validateNotOccupied(AmsAsset asset, Long currentOrderId)
+ {
+ if (amsReturnOrderMapper.countOtherActiveReturnOrderByAssetId(asset.getAssetId(), currentOrderId,
+ ReturnOrderStatus.DRAFT, ReturnOrderStatus.PENDING_CONFIRM) > 0)
+ {
+ throw new ServiceException(StringUtils.format("资产【{}】已被其他有效退库单占用", asset.getAssetCode()));
+ }
+ }
+
+ private void fillAssetSnapshots(AmsReturnOrderItem item, AmsAsset asset)
+ {
+ item.setAssetCode(asset.getAssetCode());
+ item.setAssetName(asset.getAssetName());
+ item.setCategoryId(asset.getCategoryId());
+ item.setCategoryCode(asset.getCategoryCode());
+ item.setCategoryName(asset.getCategoryName());
+ item.setSpecModel(asset.getSpecModel());
+ item.setBrand(asset.getBrand());
+ item.setAssetStatus(asset.getAssetStatus());
+ item.setBeforeDeptId(asset.getUseDeptId());
+ item.setBeforeDeptName(asset.getUseDeptName());
+ item.setBeforeUserId(asset.getUseUserId());
+ item.setBeforeUserName(asset.getUseUserName());
+ }
+
+ /** 回填最近一次已完成领用的原仓位,供页面默认带出和后端单仓库规则校验。 */
+ private AmsReturnAssetCandidate fillOriginReference(AmsReturnOrderItem item)
+ {
+ AmsReturnAssetCandidate candidate = amsReturnOrderMapper.selectReturnAssetCandidateByAssetId(item.getAssetId());
+ item.setOriginWarehouseId(candidate == null ? null : candidate.getOriginWarehouseId());
+ item.setOriginWarehouseCode(candidate == null ? null : candidate.getOriginWarehouseCode());
+ item.setOriginWarehouseName(candidate == null ? null : candidate.getOriginWarehouseName());
+ item.setOriginWarehouseEnabled(candidate == null ? null : candidate.getOriginWarehouseEnabled());
+ item.setOriginLocationId(candidate == null ? null : candidate.getOriginLocationId());
+ item.setOriginLocationCode(candidate == null ? null : candidate.getOriginLocationCode());
+ item.setOriginLocationName(candidate == null ? null : candidate.getOriginLocationName());
+ item.setOriginLocationEnabled(candidate == null ? null : candidate.getOriginLocationEnabled());
+ return candidate;
+ }
+
+ private void fillOriginReferences(AmsReturnOrder order)
+ {
+ if (order == null || order.getAmsReturnOrderItemList() == null)
+ {
+ return;
+ }
+ for (AmsReturnOrderItem item : order.getAmsReturnOrderItemList())
+ {
+ fillOriginReference(item);
+ }
+ }
+
+ private void fillTargetSnapshots(AmsReturnOrderItem item, AmsAsset asset, AmsWarehouse warehouse)
+ {
+ AmsAssetLocation location = requireEnabledLocation(item.getAfterLocationId());
+ if (!Objects.equals(warehouse.getWarehouseId(), location.getWarehouseId()))
+ {
+ throw new ServiceException(StringUtils.format("资产【{}】的退库位置不属于接收仓库", asset.getAssetCode()));
+ }
+ item.setAfterWarehouseId(warehouse.getWarehouseId());
+ item.setAfterWarehouseCode(warehouse.getWarehouseCode());
+ item.setAfterWarehouseName(warehouse.getWarehouseName());
+ item.setAfterLocationCode(location.getLocationCode());
+ item.setAfterLocationName(location.getLocationName());
+ }
+
+ private void validateBeforeSnapshot(AmsReturnOrderItem item, AmsAsset asset)
+ {
+ if (!Objects.equals(item.getBeforeDeptId(), asset.getUseDeptId())
+ || !Objects.equals(item.getBeforeUserId(), asset.getUseUserId()))
+ {
+ throw new ServiceException(StringUtils.format("资产【{}】当前使用归属已变化,请重新编辑并提交退库单",
+ asset.getAssetCode()));
+ }
+ }
+
+ private AmsWarehouse requireEnabledWarehouse(Long warehouseId)
+ {
+ if (StringUtils.isNull(warehouseId))
+ {
+ throw new ServiceException("接收仓库不能为空");
+ }
+ AmsWarehouse warehouse = amsWarehouseService.selectAmsWarehouseByWarehouseId(warehouseId);
+ if (StringUtils.isNull(warehouse) || !StringUtils.equals(ENABLED_YES, warehouse.getEnabled()))
+ {
+ throw new ServiceException("接收仓库不存在或已停用");
+ }
+ return warehouse;
+ }
+
+ private AmsAssetLocation requireEnabledLocation(Long locationId)
+ {
+ if (StringUtils.isNull(locationId))
+ {
+ throw new ServiceException("退库位置不能为空");
+ }
+ AmsAssetLocation location = amsAssetLocationService.selectAmsAssetLocationByLocationId(locationId);
+ if (StringUtils.isNull(location) || !StringUtils.equals(ENABLED_YES, location.getEnabled()))
+ {
+ throw new ServiceException("退库位置不存在或已停用");
+ }
+ return location;
+ }
+
+ private SysDept requireNormalDept(Long deptId, String fieldName)
+ {
+ if (StringUtils.isNull(deptId))
+ {
+ throw new ServiceException(fieldName + "不能为空");
+ }
+ SysDept dept = sysDeptService.selectDeptById(deptId);
+ if (StringUtils.isNull(dept)
+ || !StringUtils.equals(UserConstants.DEPT_NORMAL, dept.getStatus())
+ || !StringUtils.equals(DEL_FLAG_NORMAL, dept.getDelFlag()))
+ {
+ throw new ServiceException(fieldName + "不存在或已停用");
+ }
+ return dept;
+ }
+
+ private SysUser requireNormalUser(Long userId, String fieldName)
+ {
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException(fieldName + "不能为空");
+ }
+ SysUser user = sysUserService.selectUserById(userId);
+ if (StringUtils.isNull(user)
+ || !StringUtils.equals(UserConstants.NORMAL, user.getStatus())
+ || !StringUtils.equals(DEL_FLAG_NORMAL, user.getDelFlag()))
+ {
+ throw new ServiceException(fieldName + "不存在或已停用");
+ }
+ return user;
+ }
+
+ private AmsReturnOrder requireOrderForUpdate(Long orderId, String requiredStatus, String message)
+ {
+ if (StringUtils.isNull(orderId))
+ {
+ throw new ServiceException("退库单ID不能为空");
+ }
+ AmsReturnOrder order = amsReturnOrderMapper.selectAmsReturnOrderByOrderIdForUpdate(orderId);
+ if (StringUtils.isNull(order))
+ {
+ throw new ServiceException("退库单不存在或已删除");
+ }
+ if (!StringUtils.equals(requiredStatus, order.getOrderStatus()))
+ {
+ throw new ServiceException(message);
+ }
+ return order;
+ }
+
+ private void requireDraft(AmsReturnOrder order)
+ {
+ if (!StringUtils.equals(ReturnOrderStatus.DRAFT, order.getOrderStatus()))
+ {
+ throw new ServiceException("仅草稿退库单允许删除");
+ }
+ }
+
+ /**
+ * 按资产ID升序对明细进行排序。
+ *
+ * 避坑考量:
+ * 这是典型的防死锁(Deadlock Prevention)设计。
+ * 当多个并发事务同时操作包含多个资产的生命周期单据时,若获取行锁(`FOR UPDATE`)的顺序不一致:
+ * 例如事务A先锁资产1再锁资产2,而事务B并发先锁资产2再锁资产1,就极易引发数据库死锁。
+ * 通过在应用层使用此方法统一按资产 ID 升序排序,使所有涉及批量资产锁定的逻辑(保存、提交、确认)
+ * 拥有全局一致的锁定顺序,从根本上消除了并发死锁的风险。
+ *
+ */
+ private List sortedItems(AmsReturnOrder order)
+ {
+ if (order.getAmsReturnOrderItemList() == null)
+ {
+ return new ArrayList<>();
+ }
+ List items = new ArrayList<>(order.getAmsReturnOrderItemList());
+ items.sort(Comparator.comparing(AmsReturnOrderItem::getAssetId,
+ Comparator.nullsFirst(Long::compareTo)));
+ return items;
+ }
+
+ private void insertReturnOrderItems(AmsReturnOrder order)
+ {
+ List items = new ArrayList<>();
+ Date now = DateUtils.getNowDate();
+ for (AmsReturnOrderItem item : order.getAmsReturnOrderItemList())
+ {
+ item.setItemId(null);
+ item.setOrderId(order.getOrderId());
+ item.setCreateBy(StringUtils.isNotEmpty(order.getCreateBy()) ? order.getCreateBy() : order.getUpdateBy());
+ item.setCreateTime(now);
+ item.setUpdateBy(null);
+ item.setUpdateTime(null);
+ items.add(item);
+ }
+ if (amsReturnOrderMapper.batchAmsReturnOrderItem(items) != items.size())
+ {
+ throw new ServiceException("退库单明细保存失败");
+ }
+ }
+
+ private void validateOperator(Long userId, String userName, String fieldName)
+ {
+ if (StringUtils.isNull(userId) || StringUtils.isEmpty(StringUtils.trim(userName)))
+ {
+ throw new ServiceException(fieldName + "ID和名称不能为空");
+ }
+ validateLength(StringUtils.trim(userName), 100, fieldName + "名称");
+ }
+
+ private void validateLoginName(String loginName)
+ {
+ if (StringUtils.isEmpty(StringUtils.trim(loginName)))
+ {
+ throw new ServiceException("操作登录账号不能为空");
+ }
+ validateLength(StringUtils.trim(loginName), 64, "操作登录账号");
+ }
+
+ private void validateLength(String value, int maxLength, String fieldName)
+ {
+ if (StringUtils.isNotEmpty(value) && value.length() > maxLength)
+ {
+ throw new ServiceException(fieldName + "长度不能超过" + maxLength + "个字符");
+ }
+ }
+
+ /**
+ * 退库单原仓库一致性校验分组辅助类。
+ *
+ * 业务意图与管控:
+ * 1. “一单一仓库原则”:在一张退库单中,原则上不允许将来自不同领用仓库(即不同的 `originWarehouseId`)的资产混在一起退库。
+ * 因为现场交接和收料一般发生在特定的仓库,混杂多个原仓库会增加清点和确认流程的混乱度。
+ * 2. 来源可追溯性一致:系统严格限制“可追溯领用来源资产”(曾经过领用单流程)与“无明确来源资产”混单提交。
+ * 无来源资产由于没有历史领用仓位记录,其退库不具备自动原仓对齐校验。
+ *
+ */
+ private static class ReturnOriginGroup
+ {
+ private boolean initialized;
+
+ private boolean traceable;
+
+ private Long originWarehouseId;
+
+ /**
+ * 接收并核对资产的来源一致性。
+ *
+ * @param asset 待核查资产
+ * @param candidate 来源资产候选快照
+ * @throws ServiceException 若来源仓库类型或仓库 ID 不一致时,直接抛出业务异常阻断
+ */
+ void accept(AmsAsset asset, AmsReturnAssetCandidate candidate)
+ {
+ boolean currentTraceable = candidate != null && candidate.isOriginTraceable();
+ Long currentWarehouseId = currentTraceable ? candidate.getOriginWarehouseId() : null;
+ if (!initialized)
+ {
+ initialized = true;
+ traceable = currentTraceable;
+ originWarehouseId = currentWarehouseId;
+ return;
+ }
+ if (traceable != currentTraceable)
+ {
+ throw new ServiceException(StringUtils.format(
+ "资产【{}】与当前退库单的原领用仓位来源类型不一致", asset.getAssetCode()));
+ }
+ if (traceable && !Objects.equals(originWarehouseId, currentWarehouseId))
+ {
+ throw new ServiceException(StringUtils.format(
+ "资产【{}】的原领用仓库与当前退库单不一致", asset.getAssetCode()));
+ }
+ }
+ }
+}
diff --git a/ruoyi-asset/src/main/resources/mapper/asset/AmsReturnOrderMapper.xml b/ruoyi-asset/src/main/resources/mapper/asset/AmsReturnOrderMapper.xml
new file mode 100644
index 0000000..862cb58
--- /dev/null
+++ b/ruoyi-asset/src/main/resources/mapper/asset/AmsReturnOrderMapper.xml
@@ -0,0 +1,389 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ select order_id, return_no, applicant_id, applicant_name, apply_dept_id, apply_dept_name,
+ receive_warehouse_id, receive_warehouse_code, receive_warehouse_name,
+ confirm_user_id, confirm_user_name, confirm_time, order_status,
+ create_by, create_time, update_by, update_time, remark, del_flag
+ from ams_return_order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ insert into ams_return_order (
+ return_no, applicant_id, applicant_name, apply_dept_id, apply_dept_name,
+ receive_warehouse_id, receive_warehouse_code, receive_warehouse_name,
+ order_status, create_by, create_time, remark, del_flag
+ ) values (
+ #{returnNo}, #{applicantId}, #{applicantName}, #{applyDeptId}, #{applyDeptName},
+ #{receiveWarehouseId}, #{receiveWarehouseCode}, #{receiveWarehouseName},
+ #{orderStatus}, #{createBy}, #{createTime}, #{remark}, #{delFlag}
+ )
+
+
+
+ update ams_return_order
+ set receive_warehouse_id = #{receiveWarehouseId},
+ receive_warehouse_code = #{receiveWarehouseCode},
+ receive_warehouse_name = #{receiveWarehouseName},
+ update_by = #{updateBy},
+ update_time = #{updateTime},
+ remark = #{remark}
+ where order_id = #{orderId} and del_flag = '0' and order_status = 'DRAFT'
+
+
+
+ update ams_return_order
+ set order_status = #{orderStatus},
+ update_by = #{updateBy},
+ update_time = #{updateTime}
+ where order_id = #{orderId} and del_flag = '0' and order_status = 'DRAFT'
+
+
+
+ update ams_return_order
+ set order_status = #{orderStatus},
+ confirm_user_id = #{confirmUserId},
+ confirm_user_name = #{confirmUserName},
+ confirm_time = #{confirmTime},
+ update_by = #{updateBy},
+ update_time = #{updateTime}
+ where order_id = #{orderId} and del_flag = '0' and order_status = 'PENDING_CONFIRM'
+
+
+
+ update ams_return_order
+ set del_flag = '1'
+ where order_id = #{orderId} and del_flag = '0' and order_status = 'DRAFT'
+
+
+
+ update ams_return_order
+ set del_flag = '1'
+ where order_id in
+
+ #{orderId}
+
+ and del_flag = '0' and order_status = 'DRAFT'
+
+
+
+ update ams_return_order_item
+ set del_flag = '1'
+ where order_id in
+
+ #{orderId}
+
+ and del_flag = '0'
+
+
+
+ update ams_return_order_item
+ set del_flag = '1'
+ where order_id = #{orderId} and del_flag = '0'
+
+
+
+ insert into ams_return_order_item (
+ order_id, return_no, asset_id, asset_code, asset_name, category_id, category_code,
+ category_name, spec_model, brand, before_user_id, before_user_name, before_dept_id,
+ before_dept_name, after_warehouse_id, after_warehouse_code, after_warehouse_name,
+ after_location_id, after_location_code, after_location_name,
+ create_by, create_time, remark, del_flag
+ ) values
+
+ (
+ #{item.orderId}, #{item.returnNo}, #{item.assetId}, #{item.assetCode},
+ #{item.assetName}, #{item.categoryId}, #{item.categoryCode}, #{item.categoryName},
+ #{item.specModel}, #{item.brand}, #{item.beforeUserId}, #{item.beforeUserName},
+ #{item.beforeDeptId}, #{item.beforeDeptName}, #{item.afterWarehouseId},
+ #{item.afterWarehouseCode}, #{item.afterWarehouseName}, #{item.afterLocationId},
+ #{item.afterLocationCode}, #{item.afterLocationName}, #{item.createBy},
+ #{item.createTime}, #{item.remark}, #{item.delFlag}
+ )
+
+
+
+
diff --git a/ruoyi-asset/src/main/resources/templates/asset/return/add.html b/ruoyi-asset/src/main/resources/templates/asset/return/add.html
new file mode 100644
index 0000000..c21165d
--- /dev/null
+++ b/ruoyi-asset/src/main/resources/templates/asset/return/add.html
@@ -0,0 +1,229 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ruoyi-asset/src/main/resources/templates/asset/return/edit.html b/ruoyi-asset/src/main/resources/templates/asset/return/edit.html
new file mode 100644
index 0000000..18c1790
--- /dev/null
+++ b/ruoyi-asset/src/main/resources/templates/asset/return/edit.html
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ruoyi-asset/src/main/resources/templates/asset/return/return.html b/ruoyi-asset/src/main/resources/templates/asset/return/return.html
new file mode 100644
index 0000000..ff5daa9
--- /dev/null
+++ b/ruoyi-asset/src/main/resources/templates/asset/return/return.html
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ruoyi-asset/src/main/resources/templates/asset/return/selectAsset.html b/ruoyi-asset/src/main/resources/templates/asset/return/selectAsset.html
new file mode 100644
index 0000000..f2fc0c2
--- /dev/null
+++ b/ruoyi-asset/src/main/resources/templates/asset/return/selectAsset.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ruoyi-asset/src/main/resources/templates/asset/return/view.html b/ruoyi-asset/src/main/resources/templates/asset/return/view.html
new file mode 100644
index 0000000..d38c240
--- /dev/null
+++ b/ruoyi-asset/src/main/resources/templates/asset/return/view.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ruoyi-asset/src/test/java/com/ruoyi/asset/service/impl/AmsReturnOrderServiceImplTest.java b/ruoyi-asset/src/test/java/com/ruoyi/asset/service/impl/AmsReturnOrderServiceImplTest.java
new file mode 100644
index 0000000..ed675bd
--- /dev/null
+++ b/ruoyi-asset/src/test/java/com/ruoyi/asset/service/impl/AmsReturnOrderServiceImplTest.java
@@ -0,0 +1,376 @@
+package com.ruoyi.asset.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.Arrays;
+import java.util.List;
+import com.ruoyi.asset.constant.AssetStatus;
+import com.ruoyi.asset.constant.ReturnOrderStatus;
+import com.ruoyi.asset.domain.AmsAsset;
+import com.ruoyi.asset.domain.AmsAssetLocation;
+import com.ruoyi.asset.domain.AmsReturnAssetCandidate;
+import com.ruoyi.asset.domain.AmsReturnOrder;
+import com.ruoyi.asset.domain.AmsReturnOrderItem;
+import com.ruoyi.asset.domain.AmsWarehouse;
+import com.ruoyi.asset.domain.AssetTransitionContext;
+import com.ruoyi.asset.mapper.AmsAssetMapper;
+import com.ruoyi.asset.mapper.AmsReturnOrderMapper;
+import com.ruoyi.asset.service.IAmsAssetLocationService;
+import com.ruoyi.asset.service.IAmsWarehouseService;
+import com.ruoyi.asset.service.IAssetStatusTransitionService;
+import com.ruoyi.common.core.domain.entity.SysDept;
+import com.ruoyi.common.core.domain.entity.SysUser;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.service.ISysCodeRuleService;
+import com.ruoyi.system.service.ISysDeptService;
+import com.ruoyi.system.service.ISysUserService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class AmsReturnOrderServiceImplTest
+{
+ @Mock
+ private AmsReturnOrderMapper amsReturnOrderMapper;
+
+ @Mock
+ private AmsAssetMapper amsAssetMapper;
+
+ @Mock
+ private ISysCodeRuleService sysCodeRuleService;
+
+ @Mock
+ private IAmsWarehouseService amsWarehouseService;
+
+ @Mock
+ private IAmsAssetLocationService amsAssetLocationService;
+
+ @Mock
+ private ISysDeptService sysDeptService;
+
+ @Mock
+ private ISysUserService sysUserService;
+
+ @Mock
+ private IAssetStatusTransitionService assetStatusTransitionService;
+
+ @InjectMocks
+ private AmsReturnOrderServiceImpl service;
+
+ /** 新增退库草稿应生成单号,并使用台账及主数据回填快照。 */
+ @Test
+ void insertShouldGenerateCodeAndFillSnapshots()
+ {
+ AmsReturnOrder order = buildRequest();
+ stubApplicant();
+ stubInUseAsset();
+ stubTargetWarehouseLocation();
+ when(sysCodeRuleService.nextCode("RETURN_ORDER")).thenReturn("TK202606150001");
+ doAnswer(invocation -> {
+ AmsReturnOrder inserted = invocation.getArgument(0);
+ inserted.setOrderId(100L);
+ return 1;
+ }).when(amsReturnOrderMapper).insertAmsReturnOrder(any(AmsReturnOrder.class));
+ when(amsReturnOrderMapper.batchAmsReturnOrderItem(anyList()))
+ .thenAnswer(invocation -> ((List>) invocation.getArgument(0)).size());
+
+ assertEquals(1, service.insertAmsReturnOrder(order, 1L, "管理员", 103L));
+
+ AmsReturnOrderItem item = order.getAmsReturnOrderItemList().get(0);
+ assertEquals("TK202606150001", order.getReturnNo());
+ assertEquals(ReturnOrderStatus.DRAFT, order.getOrderStatus());
+ assertEquals("研发部门", order.getApplyDeptName());
+ assertEquals("二号仓", order.getReceiveWarehouseName());
+ assertEquals("测试部门", item.getBeforeDeptName());
+ assertEquals("测试用户", item.getBeforeUserName());
+ assertEquals("二号仓A区", item.getAfterLocationName());
+ assertEquals(100L, item.getOrderId());
+ assertNotNull(item.getCreateTime());
+ }
+
+ /** 退库位置必须属于主表所选接收仓库。 */
+ @Test
+ void insertShouldRejectLocationOutsideWarehouse()
+ {
+ AmsReturnOrder order = buildRequest();
+ stubApplicant();
+ stubInUseAsset();
+ stubTargetWarehouseLocation();
+ when(sysCodeRuleService.nextCode("RETURN_ORDER")).thenReturn("TK202606150001");
+ AmsAssetLocation location = new AmsAssetLocation();
+ location.setLocationId(20L);
+ location.setWarehouseId(99L);
+ location.setEnabled("Y");
+ when(amsAssetLocationService.selectAmsAssetLocationByLocationId(20L)).thenReturn(location);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.insertAmsReturnOrder(order, 1L, "管理员", 103L));
+
+ assertTrue(exception.getMessage().contains("不属于接收仓库"));
+ verify(amsReturnOrderMapper, never()).insertAmsReturnOrder(any(AmsReturnOrder.class));
+ }
+
+ /** 被其他草稿或待确认退库单占用的资产不得再次加入退库单。 */
+ @Test
+ void insertShouldRejectAssetOccupiedByOtherActiveReturn()
+ {
+ AmsReturnOrder order = buildRequest();
+ stubApplicant();
+ stubInUseAsset();
+ stubTargetWarehouse();
+ when(sysCodeRuleService.nextCode("RETURN_ORDER")).thenReturn("TK202606150001");
+ when(amsReturnOrderMapper.countOtherActiveReturnOrderByAssetId(1L, null,
+ ReturnOrderStatus.DRAFT, ReturnOrderStatus.PENDING_CONFIRM)).thenReturn(1);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.insertAmsReturnOrder(order, 1L, "管理员", 103L));
+
+ assertTrue(exception.getMessage().contains("其他有效退库单"));
+ verify(amsReturnOrderMapper, never()).insertAmsReturnOrder(any(AmsReturnOrder.class));
+ }
+
+ /** 同一退库单中的可追溯资产必须来自同一领用前仓库。 */
+ @Test
+ void insertShouldRejectAssetsFromDifferentOriginWarehouses()
+ {
+ AmsReturnOrder order = buildRequestWithAssets(1L, 2L);
+ stubApplicant();
+ stubInUseAsset(1L);
+ stubInUseAsset(2L);
+ stubTargetWarehouseLocation();
+ when(sysCodeRuleService.nextCode("RETURN_ORDER")).thenReturn("TK202606150001");
+ when(amsReturnOrderMapper.selectReturnAssetCandidateByAssetId(1L))
+ .thenReturn(buildCandidate(1L, 10L));
+ when(amsReturnOrderMapper.selectReturnAssetCandidateByAssetId(2L))
+ .thenReturn(buildCandidate(2L, 20L));
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.insertAmsReturnOrder(order, 1L, "管理员", 103L));
+
+ assertTrue(exception.getMessage().contains("原领用仓库"));
+ verify(amsReturnOrderMapper, never()).insertAmsReturnOrder(any(AmsReturnOrder.class));
+ }
+
+ /** 缺少领用来源的资产不能与可追溯资产混在同一退库单。 */
+ @Test
+ void insertShouldRejectMixedTraceableAndMissingOrigin()
+ {
+ AmsReturnOrder order = buildRequestWithAssets(1L, 2L);
+ stubApplicant();
+ stubInUseAsset(1L);
+ stubInUseAsset(2L);
+ stubTargetWarehouseLocation();
+ when(sysCodeRuleService.nextCode("RETURN_ORDER")).thenReturn("TK202606150001");
+ when(amsReturnOrderMapper.selectReturnAssetCandidateByAssetId(1L))
+ .thenReturn(buildCandidate(1L, 10L));
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.insertAmsReturnOrder(order, 1L, "管理员", 103L));
+
+ assertTrue(exception.getMessage().contains("来源类型不一致"));
+ verify(amsReturnOrderMapper, never()).insertAmsReturnOrder(any(AmsReturnOrder.class));
+ }
+
+ /** 草稿提交前校验资产仍在用且使用归属未变化。 */
+ @Test
+ void submitShouldMoveToPendingConfirm()
+ {
+ AmsReturnOrder order = buildPersistedOrder(ReturnOrderStatus.DRAFT);
+ when(amsReturnOrderMapper.selectAmsReturnOrderByOrderIdForUpdate(100L)).thenReturn(order);
+ stubInUseAsset();
+ stubTargetWarehouseLocation();
+ when(amsReturnOrderMapper.submitAmsReturnOrder(any(AmsReturnOrder.class))).thenReturn(1);
+
+ assertEquals(1, service.submitReturn(100L, "admin"));
+
+ assertEquals(ReturnOrderStatus.PENDING_CONFIRM, order.getOrderStatus());
+ verify(amsReturnOrderMapper).submitAmsReturnOrder(order);
+ }
+
+ /** 确认退库应委托公共流转服务,并记录确认信息。 */
+ @Test
+ void confirmShouldDelegateTransitionAndCompleteOrder()
+ {
+ AmsReturnOrder order = buildPersistedOrder(ReturnOrderStatus.PENDING_CONFIRM);
+ when(amsReturnOrderMapper.selectAmsReturnOrderByOrderIdForUpdate(100L)).thenReturn(order);
+ stubInUseAsset();
+ stubTargetWarehouseLocation();
+ when(amsReturnOrderMapper.confirmAmsReturnOrder(any(AmsReturnOrder.class))).thenReturn(1);
+
+ assertEquals(1, service.confirmReturn(100L, 1L, "管理员", "admin"));
+
+ ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(AssetTransitionContext.class);
+ verify(assetStatusTransitionService).confirmReturn(
+ org.mockito.ArgumentMatchers.eq(1L),
+ org.mockito.ArgumentMatchers.eq(2L),
+ org.mockito.ArgumentMatchers.eq(20L),
+ contextCaptor.capture());
+ assertEquals(100L, contextCaptor.getValue().getSourceOrderId());
+ assertEquals(101L, contextCaptor.getValue().getSourceItemId());
+ assertEquals(ReturnOrderStatus.RETURNED, order.getOrderStatus());
+ assertEquals("管理员", order.getConfirmUserName());
+ assertNotNull(order.getConfirmTime());
+ }
+
+ /** 草稿保存后使用人发生变化时,提交必须阻断。 */
+ @Test
+ void submitShouldRejectStaleUseOwnership()
+ {
+ AmsReturnOrder order = buildPersistedOrder(ReturnOrderStatus.DRAFT);
+ order.getAmsReturnOrderItemList().get(0).setBeforeUserId(99L);
+ when(amsReturnOrderMapper.selectAmsReturnOrderByOrderIdForUpdate(100L)).thenReturn(order);
+ stubInUseAsset();
+ stubTargetWarehouse();
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.submitReturn(100L, "admin"));
+
+ assertTrue(exception.getMessage().contains("当前使用归属已变化"));
+ verify(amsReturnOrderMapper, never()).submitAmsReturnOrder(any(AmsReturnOrder.class));
+ }
+
+ /** 非草稿退库单不得删除。 */
+ @Test
+ void deleteShouldRejectNonDraftOrder()
+ {
+ AmsReturnOrder order = new AmsReturnOrder();
+ order.setOrderId(100L);
+ order.setOrderStatus(ReturnOrderStatus.PENDING_CONFIRM);
+ when(amsReturnOrderMapper.selectAmsReturnOrderByOrderIdForUpdate(100L)).thenReturn(order);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.deleteAmsReturnOrderByOrderId(100L));
+
+ assertTrue(exception.getMessage().contains("仅草稿"));
+ verify(amsReturnOrderMapper, never()).deleteAmsReturnOrderByOrderId(100L);
+ }
+
+ private AmsReturnOrder buildRequest()
+ {
+ AmsReturnOrder order = new AmsReturnOrder();
+ order.setCreateBy("admin");
+ order.setReceiveWarehouseId(2L);
+ AmsReturnOrderItem item = new AmsReturnOrderItem();
+ item.setAssetId(1L);
+ item.setAfterLocationId(20L);
+ order.setAmsReturnOrderItemList(Collections.singletonList(item));
+ return order;
+ }
+
+ private AmsReturnOrder buildRequestWithAssets(Long... assetIds)
+ {
+ AmsReturnOrder order = new AmsReturnOrder();
+ order.setCreateBy("admin");
+ order.setReceiveWarehouseId(2L);
+ order.setAmsReturnOrderItemList(Arrays.stream(assetIds).map(assetId -> {
+ AmsReturnOrderItem item = new AmsReturnOrderItem();
+ item.setAssetId(assetId);
+ item.setAfterLocationId(20L);
+ return item;
+ }).toList());
+ return order;
+ }
+
+ private AmsReturnOrder buildPersistedOrder(String status)
+ {
+ AmsReturnOrder order = buildRequest();
+ order.setOrderId(100L);
+ order.setReturnNo("TK202606150001");
+ order.setOrderStatus(status);
+ AmsReturnOrderItem item = order.getAmsReturnOrderItemList().get(0);
+ item.setItemId(101L);
+ item.setBeforeDeptId(105L);
+ item.setBeforeUserId(2L);
+ item.setAfterWarehouseId(2L);
+ return order;
+ }
+
+ private void stubApplicant()
+ {
+ SysDept dept = new SysDept();
+ dept.setDeptId(103L);
+ dept.setDeptName("研发部门");
+ dept.setStatus("0");
+ dept.setDelFlag("0");
+ when(sysDeptService.selectDeptById(103L)).thenReturn(dept);
+
+ SysUser user = new SysUser();
+ user.setUserId(1L);
+ user.setUserName("管理员");
+ user.setDeptId(103L);
+ user.setStatus("0");
+ user.setDelFlag("0");
+ when(sysUserService.selectUserById(1L)).thenReturn(user);
+ }
+
+ private void stubInUseAsset()
+ {
+ stubInUseAsset(1L);
+ }
+
+ private void stubInUseAsset(Long assetId)
+ {
+ AmsAsset asset = new AmsAsset();
+ asset.setAssetId(assetId);
+ asset.setAssetCode("ASSET-" + assetId);
+ asset.setAssetName("测试资产");
+ asset.setCategoryId(3L);
+ asset.setCategoryCode("CAT-003");
+ asset.setCategoryName("测试类别");
+ asset.setAssetStatus(AssetStatus.IN_USE);
+ asset.setUseDeptId(105L);
+ asset.setUseDeptName("测试部门");
+ asset.setUseUserId(2L);
+ asset.setUseUserName("测试用户");
+ when(amsAssetMapper.selectAmsAssetByAssetIdForUpdate(assetId)).thenReturn(asset);
+ }
+
+ private AmsReturnAssetCandidate buildCandidate(Long warehouseId, Long locationId)
+ {
+ AmsReturnAssetCandidate candidate = new AmsReturnAssetCandidate();
+ candidate.setOriginWarehouseId(warehouseId);
+ candidate.setOriginWarehouseName("原仓库" + warehouseId);
+ candidate.setOriginWarehouseEnabled("Y");
+ candidate.setOriginLocationId(locationId);
+ candidate.setOriginLocationName("原位置" + locationId);
+ candidate.setOriginLocationEnabled("Y");
+ return candidate;
+ }
+
+ private void stubTargetWarehouseLocation()
+ {
+ stubTargetWarehouse();
+
+ AmsAssetLocation location = new AmsAssetLocation();
+ location.setLocationId(20L);
+ location.setLocationCode("LOC-020");
+ location.setLocationName("二号仓A区");
+ location.setWarehouseId(2L);
+ location.setEnabled("Y");
+ when(amsAssetLocationService.selectAmsAssetLocationByLocationId(20L)).thenReturn(location);
+ }
+
+ private void stubTargetWarehouse()
+ {
+ AmsWarehouse warehouse = new AmsWarehouse();
+ warehouse.setWarehouseId(2L);
+ warehouse.setWarehouseCode("WH-002");
+ warehouse.setWarehouseName("二号仓");
+ warehouse.setEnabled("Y");
+ when(amsWarehouseService.selectAmsWarehouseByWarehouseId(2L)).thenReturn(warehouse);
+ }
+}