From 5f15c3f1f99b32e2c4d3cdf89d0ab0db5f30ea97 Mon Sep 17 00:00:00 2001 From: yangk Date: Wed, 17 Jun 2026 13:57:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(asset):=20=E6=B7=BB=E5=8A=A0=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E6=8A=A5=E4=BF=AE=E7=BB=B4=E4=BF=AE=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增报修单创建页面,支持资产选择和故障描述录入 - 添加报修操作上下文类,统一管理操作人信息 - 创建报修订单实体类,定义完整的报修流程字段结构 - 实现报修订单控制器,提供从新增到完成的完整业务流程 - 添加报修订单数据访问层,支持查询和状态变更操作 - 集成报表单模板,实现前端表单验证和交互逻辑 --- .gitignore | 3 + .../controller/AmsRepairOrderController.java | 279 ++++++++ .../asset/domain/AmsRepairOperateContext.java | 65 ++ .../ruoyi/asset/domain/AmsRepairOrder.java | 453 +++++++++++++ .../asset/mapper/AmsRepairOrderMapper.java | 97 +++ .../asset/service/IAmsRepairOrderService.java | 119 ++++ .../impl/AmsRepairOrderServiceImpl.java | 602 ++++++++++++++++++ .../mapper/asset/AmsRepairOrderMapper.xml | 303 +++++++++ .../resources/templates/asset/repair/add.html | 174 +++++ .../templates/asset/repair/edit.html | 187 ++++++ .../templates/asset/repair/finish.html | 81 +++ .../templates/asset/repair/repair.html | 252 ++++++++ .../templates/asset/repair/selectAsset.html | 81 +++ .../templates/asset/repair/start.html | 130 ++++ .../templates/asset/repair/view.html | 244 +++++++ .../impl/AmsRepairOrderServiceImplTest.java | 492 ++++++++++++++ 16 files changed, 3562 insertions(+) create mode 100644 ruoyi-asset/src/main/java/com/ruoyi/asset/controller/AmsRepairOrderController.java create mode 100644 ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsRepairOperateContext.java create mode 100644 ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsRepairOrder.java create mode 100644 ruoyi-asset/src/main/java/com/ruoyi/asset/mapper/AmsRepairOrderMapper.java create mode 100644 ruoyi-asset/src/main/java/com/ruoyi/asset/service/IAmsRepairOrderService.java create mode 100644 ruoyi-asset/src/main/java/com/ruoyi/asset/service/impl/AmsRepairOrderServiceImpl.java create mode 100644 ruoyi-asset/src/main/resources/mapper/asset/AmsRepairOrderMapper.xml create mode 100644 ruoyi-asset/src/main/resources/templates/asset/repair/add.html create mode 100644 ruoyi-asset/src/main/resources/templates/asset/repair/edit.html create mode 100644 ruoyi-asset/src/main/resources/templates/asset/repair/finish.html create mode 100644 ruoyi-asset/src/main/resources/templates/asset/repair/repair.html create mode 100644 ruoyi-asset/src/main/resources/templates/asset/repair/selectAsset.html create mode 100644 ruoyi-asset/src/main/resources/templates/asset/repair/start.html create mode 100644 ruoyi-asset/src/main/resources/templates/asset/repair/view.html create mode 100644 ruoyi-asset/src/test/java/com/ruoyi/asset/service/impl/AmsRepairOrderServiceImplTest.java diff --git a/.gitignore b/.gitignore index 6901051..a182994 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ doc/ !*/build/*.java !*/build/*.html !*/build/*.xml + +# Exclude sql directory +sql/ diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/controller/AmsRepairOrderController.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/controller/AmsRepairOrderController.java new file mode 100644 index 0000000..88e8528 --- /dev/null +++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/controller/AmsRepairOrderController.java @@ -0,0 +1,279 @@ +package com.ruoyi.asset.controller; + +import java.util.List; +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; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.constant.UserConstants; +import com.ruoyi.asset.domain.AmsAsset; +import com.ruoyi.asset.domain.AmsRepairOperateContext; +import com.ruoyi.asset.domain.AmsRepairOrder; +import com.ruoyi.asset.service.IAmsRepairOrderService; +import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.common.core.page.TableDataInfo; + +/** + * 报修维修管理Controller + * + * 核心职责:提供报修单的新增草稿、修改、逻辑删除、提交申请、确认受理、驳回、开始维修、完成维修等API接口。 + * + * @author Yangk + * @date 2026-06-16 + */ +@Controller +@RequestMapping("/asset/repair") +public class AmsRepairOrderController extends BaseController +{ + private String prefix = "asset/repair"; + + @Autowired + private IAmsRepairOrderService amsRepairOrderService; + + @Autowired + private ISysUserService sysUserService; + + @RequiresPermissions("asset:repair:view") + @GetMapping() + public String repair() + { + return prefix + "/repair"; + } + + /** + * 查询报修维修管理列表 + */ + @RequiresPermissions("asset:repair:list") + @PostMapping("/list") + @ResponseBody + public TableDataInfo list(AmsRepairOrder amsRepairOrder) + { + startPage(); + List list = amsRepairOrderService.selectAmsRepairOrderList(amsRepairOrder); + return getDataTable(list); + } + + /** + * 弹出资产选择模态框 + */ + @RequiresPermissions(value = { "asset:repair:add", "asset:repair:edit" }, logical = Logical.OR) + @GetMapping("/selectAsset") + public String selectAsset(@RequestParam(value = "repairId", required = false) Long repairId, ModelMap mmap) + { + mmap.put("repairId", repairId); + return prefix + "/selectAsset"; + } + + /** + * 查询可用于当前报修单选择的资产列表(在库/在用且未被占用) + */ + @RequiresPermissions(value = { "asset:repair:add", "asset:repair:edit" }, logical = Logical.OR) + @PostMapping("/availableAssetList") + @ResponseBody + public TableDataInfo availableAssetList(AmsAsset asset, @RequestParam(value = "repairId", required = false) Long repairId) + { + startPage(); + List list = amsRepairOrderService.selectAvailableRepairAssetList(asset, repairId); + return getDataTable(list); + } + + /** + * 导出报修维修管理列表 + */ + @RequiresPermissions("asset:repair:export") + @Log(title = "报修维修管理", businessType = BusinessType.EXPORT) + @PostMapping("/export") + @ResponseBody + public AjaxResult export(AmsRepairOrder amsRepairOrder) + { + List list = amsRepairOrderService.selectAmsRepairOrderList(amsRepairOrder); + ExcelUtil util = new ExcelUtil(AmsRepairOrder.class); + return util.exportExcel(list, "报修维修管理数据"); + } + + /** + * 查看报修维修管理详情 + */ + @RequiresPermissions("asset:repair:view") + @GetMapping("/view/{repairId}") + public String view(@PathVariable("repairId") Long repairId, ModelMap mmap) + { + AmsRepairOrder amsRepairOrder = amsRepairOrderService.selectAmsRepairOrderByRepairId(repairId); + mmap.put("amsRepairOrder", amsRepairOrder); + return prefix + "/view"; + } + + /** + * 新增报修维修管理 + */ + @RequiresPermissions("asset:repair:add") + @GetMapping("/add") + public String add(ModelMap mmap) + { + putUserOptions(mmap); + return prefix + "/add"; + } + + /** + * 新增保存报修维修管理(草稿) + */ + @RequiresPermissions("asset:repair:add") + @Log(title = "报修维修管理", businessType = BusinessType.INSERT) + @PostMapping("/add") + @ResponseBody + public AjaxResult addSave(AmsRepairOrder amsRepairOrder) + { + return toAjax(amsRepairOrderService.insertAmsRepairOrder(amsRepairOrder, buildOperateContext())); + } + + /** + * 修改报修维修管理 + */ + @RequiresPermissions("asset:repair:edit") + @GetMapping("/edit/{repairId}") + public String edit(@PathVariable("repairId") Long repairId, ModelMap mmap) + { + AmsRepairOrder amsRepairOrder = amsRepairOrderService.selectAmsRepairOrderByRepairId(repairId); + mmap.put("amsRepairOrder", amsRepairOrder); + putUserOptions(mmap); + return prefix + "/edit"; + } + + /** + * 修改保存报修维修管理(草稿) + */ + @RequiresPermissions("asset:repair:edit") + @Log(title = "报修维修管理", businessType = BusinessType.UPDATE) + @PostMapping("/edit") + @ResponseBody + public AjaxResult editSave(AmsRepairOrder amsRepairOrder) + { + return toAjax(amsRepairOrderService.updateAmsRepairOrder(amsRepairOrder, buildOperateContext())); + } + + /** + * 删除报修维修管理(仅限草稿) + */ + @RequiresPermissions("asset:repair:remove") + @Log(title = "报修维修管理", businessType = BusinessType.DELETE) + @PostMapping("/remove") + @ResponseBody + public AjaxResult remove(String ids) + { + return toAjax(amsRepairOrderService.deleteAmsRepairOrderByRepairIds(ids)); + } + + /** + * 提交报修申请 + */ + @RequiresPermissions("asset:repair:submit") + @Log(title = "报修维修管理", businessType = BusinessType.UPDATE) + @PostMapping("/submit/{repairId}") + @ResponseBody + public AjaxResult submit(@PathVariable("repairId") Long repairId) + { + return toAjax(amsRepairOrderService.submitRepair(repairId, buildOperateContext())); + } + + /** + * 确认受理报修 + */ + @RequiresPermissions("asset:repair:confirm") + @Log(title = "报修维修管理", businessType = BusinessType.UPDATE) + @PostMapping("/confirm/{repairId}") + @ResponseBody + public AjaxResult confirm(@PathVariable("repairId") Long repairId) + { + return toAjax(amsRepairOrderService.confirmRepair(repairId, buildOperateContext())); + } + + /** + * 驳回报修申请 + */ + @RequiresPermissions("asset:repair:reject") + @Log(title = "报修维修管理", businessType = BusinessType.UPDATE) + @PostMapping("/reject/{repairId}") + @ResponseBody + public AjaxResult reject(@PathVariable("repairId") Long repairId, @RequestParam("rejectReason") String rejectReason) + { + return toAjax(amsRepairOrderService.rejectRepair(repairId, rejectReason, buildOperateContext())); + } + + /** + * 开始维修弹窗页面 + */ + @RequiresPermissions("asset:repair:start") + @GetMapping("/start/{repairId}") + public String start(@PathVariable("repairId") Long repairId, ModelMap mmap) + { + AmsRepairOrder amsRepairOrder = amsRepairOrderService.selectAmsRepairOrderByRepairId(repairId); + mmap.put("amsRepairOrder", amsRepairOrder); + putUserOptions(mmap); + return prefix + "/start"; + } + + /** + * 开始维修保存 + */ + @RequiresPermissions("asset:repair:start") + @Log(title = "报修维修管理", businessType = BusinessType.UPDATE) + @PostMapping("/start") + @ResponseBody + public AjaxResult startSave(AmsRepairOrder amsRepairOrder) + { + return toAjax(amsRepairOrderService.startRepair(amsRepairOrder, buildOperateContext())); + } + + /** + * 完成维修弹窗页面 + */ + @RequiresPermissions("asset:repair:finish") + @GetMapping("/finish/{repairId}") + public String finish(@PathVariable("repairId") Long repairId, ModelMap mmap) + { + AmsRepairOrder amsRepairOrder = amsRepairOrderService.selectAmsRepairOrderByRepairId(repairId); + mmap.put("amsRepairOrder", amsRepairOrder); + return prefix + "/finish"; + } + + /** + * 完成维修保存 + */ + @RequiresPermissions("asset:repair:finish") + @Log(title = "报修维修管理", businessType = BusinessType.UPDATE) + @PostMapping("/finish") + @ResponseBody + public AjaxResult finishSave(AmsRepairOrder amsRepairOrder) + { + return toAjax(amsRepairOrderService.finishRepair(amsRepairOrder, buildOperateContext())); + } + + private AmsRepairOperateContext buildOperateContext() + { + SysUser currentUser = getSysUser(); + return new AmsRepairOperateContext(currentUser.getUserId(), currentUser.getUserName(), getLoginName()); + } + + private void putUserOptions(ModelMap mmap) + { + SysUser user = new SysUser(); + user.setStatus(UserConstants.NORMAL); + mmap.put("userList", sysUserService.selectUserList(user)); + SysUser currentUser = getSysUser(); + mmap.put("defaultReportUserId", currentUser.getUserId()); + mmap.put("defaultReportUserName", currentUser.getUserName()); + } +} diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsRepairOperateContext.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsRepairOperateContext.java new file mode 100644 index 0000000..2d0f28b --- /dev/null +++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsRepairOperateContext.java @@ -0,0 +1,65 @@ +package com.ruoyi.asset.domain; + +import java.io.Serializable; + +/** + * 报修维修操作上下文 + * + * 用一个对象承载当前登录操作人,避免 Controller 和 Service 之间长期传递散落的用户参数。 + * + * @author Yangk + */ +public class AmsRepairOperateContext implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 操作人ID */ + private Long operateUserId; + + /** 操作人名称 */ + private String operateUserName; + + /** 操作人登录账号 */ + private String operateLoginName; + + public AmsRepairOperateContext() + { + } + + public AmsRepairOperateContext(Long operateUserId, String operateUserName, String operateLoginName) + { + this.operateUserId = operateUserId; + this.operateUserName = operateUserName; + this.operateLoginName = operateLoginName; + } + + public Long getOperateUserId() + { + return operateUserId; + } + + public void setOperateUserId(Long operateUserId) + { + this.operateUserId = operateUserId; + } + + public String getOperateUserName() + { + return operateUserName; + } + + public void setOperateUserName(String operateUserName) + { + this.operateUserName = operateUserName; + } + + public String getOperateLoginName() + { + return operateLoginName; + } + + public void setOperateLoginName(String operateLoginName) + { + this.operateLoginName = operateLoginName; + } +} diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsRepairOrder.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsRepairOrder.java new file mode 100644 index 0000000..a0e995c --- /dev/null +++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/domain/AmsRepairOrder.java @@ -0,0 +1,453 @@ +package com.ruoyi.asset.domain; + +import java.math.BigDecimal; +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_repair_order + * + * @author Yangk + * @date 2026-06-16 + */ +public class AmsRepairOrder extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 维修单ID */ + private Long repairId; + + /** 报修单号 */ + @Excel(name = "报修单号") + private String repairNo; + + /** 资产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; + + /** 报修人ID */ + @Excel(name = "报修人ID") + private Long reportUserId; + + /** 报修人名称快照 */ + @Excel(name = "报修人名称快照") + private String reportUserName; + + /** 故障描述 */ + @Excel(name = "故障描述") + private String faultDesc; + + /** 报修时间 */ + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @Excel(name = "报修时间", width = 30, dateFormat = "yyyy-MM-dd") + private Date reportTime; + + /** 预计完成时间 */ + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @Excel(name = "预计完成时间", width = 30, dateFormat = "yyyy-MM-dd") + private Date expectedFinishTime; + + /** 维修方类型 */ + @Excel(name = "维修方类型") + private String repairerType; + + /** 维修人ID,选择系统用户时保存 */ + @Excel(name = "维修人ID,选择系统用户时保存") + private Long repairUserId; + + /** 维修人名称快照或手工填写姓名 */ + @Excel(name = "维修人名称快照或手工填写姓名") + private String repairUserName; + + /** 外部维修单位名称 */ + @Excel(name = "外部维修单位名称") + private String repairOrgName; + + /** 维修联系电话 */ + @Excel(name = "维修联系电话") + private String repairContactPhone; + + /** 开始维修时间 */ + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @Excel(name = "开始维修时间", width = 30, dateFormat = "yyyy-MM-dd") + private Date repairStartTime; + + /** 维修完成时间 */ + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @Excel(name = "维修完成时间", width = 30, dateFormat = "yyyy-MM-dd") + private Date repairFinishTime; + + /** 维修结果 */ + @Excel(name = "维修结果") + private String repairResult; + + /** 维修费用 */ + @Excel(name = "维修费用") + private BigDecimal repairCost; + + /** 维修前资产状态 */ + @Excel(name = "维修前资产状态") + private String beforeAssetStatus; + + /** 驳回原因 */ + @Excel(name = "驳回原因") + private String rejectReason; + + /** 单据状态 */ + @Excel(name = "单据状态") + private String orderStatus; + + /** 删除标志:0存在,1删除 */ + private String delFlag; + + public void setRepairId(Long repairId) + { + this.repairId = repairId; + } + + public Long getRepairId() + { + return repairId; + } + + public void setRepairNo(String repairNo) + { + this.repairNo = repairNo; + } + + public String getRepairNo() + { + return repairNo; + } + + 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 setReportUserId(Long reportUserId) + { + this.reportUserId = reportUserId; + } + + public Long getReportUserId() + { + return reportUserId; + } + + public void setReportUserName(String reportUserName) + { + this.reportUserName = reportUserName; + } + + public String getReportUserName() + { + return reportUserName; + } + + public void setFaultDesc(String faultDesc) + { + this.faultDesc = faultDesc; + } + + public String getFaultDesc() + { + return faultDesc; + } + + public void setReportTime(Date reportTime) + { + this.reportTime = reportTime; + } + + public Date getReportTime() + { + return reportTime; + } + + public void setExpectedFinishTime(Date expectedFinishTime) + { + this.expectedFinishTime = expectedFinishTime; + } + + public Date getExpectedFinishTime() + { + return expectedFinishTime; + } + + public void setRepairerType(String repairerType) + { + this.repairerType = repairerType; + } + + public String getRepairerType() + { + return repairerType; + } + + public void setRepairUserId(Long repairUserId) + { + this.repairUserId = repairUserId; + } + + public Long getRepairUserId() + { + return repairUserId; + } + + public void setRepairUserName(String repairUserName) + { + this.repairUserName = repairUserName; + } + + public String getRepairUserName() + { + return repairUserName; + } + + public void setRepairOrgName(String repairOrgName) + { + this.repairOrgName = repairOrgName; + } + + public String getRepairOrgName() + { + return repairOrgName; + } + + public void setRepairContactPhone(String repairContactPhone) + { + this.repairContactPhone = repairContactPhone; + } + + public String getRepairContactPhone() + { + return repairContactPhone; + } + + public void setRepairStartTime(Date repairStartTime) + { + this.repairStartTime = repairStartTime; + } + + public Date getRepairStartTime() + { + return repairStartTime; + } + + public void setRepairFinishTime(Date repairFinishTime) + { + this.repairFinishTime = repairFinishTime; + } + + public Date getRepairFinishTime() + { + return repairFinishTime; + } + + public void setRepairResult(String repairResult) + { + this.repairResult = repairResult; + } + + public String getRepairResult() + { + return repairResult; + } + + public void setRepairCost(BigDecimal repairCost) + { + this.repairCost = repairCost; + } + + public BigDecimal getRepairCost() + { + return repairCost; + } + + public void setBeforeAssetStatus(String beforeAssetStatus) + { + this.beforeAssetStatus = beforeAssetStatus; + } + + public String getBeforeAssetStatus() + { + return beforeAssetStatus; + } + + public void setRejectReason(String rejectReason) + { + this.rejectReason = rejectReason; + } + + public String getRejectReason() + { + return rejectReason; + } + + 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; + } + + @Override + public String toString() { + return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + .append("repairId", getRepairId()) + .append("repairNo", getRepairNo()) + .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("reportUserId", getReportUserId()) + .append("reportUserName", getReportUserName()) + .append("faultDesc", getFaultDesc()) + .append("reportTime", getReportTime()) + .append("expectedFinishTime", getExpectedFinishTime()) + .append("repairerType", getRepairerType()) + .append("repairUserId", getRepairUserId()) + .append("repairUserName", getRepairUserName()) + .append("repairOrgName", getRepairOrgName()) + .append("repairContactPhone", getRepairContactPhone()) + .append("repairStartTime", getRepairStartTime()) + .append("repairFinishTime", getRepairFinishTime()) + .append("repairResult", getRepairResult()) + .append("repairCost", getRepairCost()) + .append("beforeAssetStatus", getBeforeAssetStatus()) + .append("rejectReason", getRejectReason()) + .append("orderStatus", getOrderStatus()) + .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/AmsRepairOrderMapper.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/mapper/AmsRepairOrderMapper.java new file mode 100644 index 0000000..0c15aa7 --- /dev/null +++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/mapper/AmsRepairOrderMapper.java @@ -0,0 +1,97 @@ +package com.ruoyi.asset.mapper; + +import java.util.List; +import com.ruoyi.asset.domain.AmsAsset; +import com.ruoyi.asset.domain.AmsRepairOrder; +import org.apache.ibatis.annotations.Param; + +/** + * 报修维修管理Mapper接口 + * + * @author Yangk + * @date 2026-06-16 + */ +public interface AmsRepairOrderMapper +{ + /** + * 查询报修维修管理 + * + * @param repairId 报修维修管理主键 + * @return 报修维修管理 + */ + public AmsRepairOrder selectAmsRepairOrderByRepairId(Long repairId); + + /** + * 悲观独占锁查询报修维修管理 + * + * @param repairId 报修维修管理主键 + * @return 报修维修管理 + */ + public AmsRepairOrder selectAmsRepairOrderByRepairIdForUpdate(Long repairId); + + /** + * 查询报修维修管理列表 + * + * @param amsRepairOrder 报修维修管理 + * @return 报修维修管理集合 + */ + public List selectAmsRepairOrderList(AmsRepairOrder amsRepairOrder); + + /** + * 新增报修维修管理 + * + * @param amsRepairOrder 报修维修管理 + * @return 结果 + */ + public int insertAmsRepairOrder(AmsRepairOrder amsRepairOrder); + + /** + * 修改报修维修管理 + * + * @param amsRepairOrder 报修维修管理 + * @return 结果 + */ + public int updateAmsRepairOrder(AmsRepairOrder amsRepairOrder); + + /** + * 删除报修维修管理(逻辑删除) + * + * @param repairId 报修维修管理主键 + * @return 结果 + */ + public int deleteAmsRepairOrderByRepairId(Long repairId); + + /** + * 批量删除报修维修管理(逻辑删除) + * + * @param repairIds 需要删除的数据主键集合 + * @return 结果 + */ + public int deleteAmsRepairOrderByRepairIds(String[] repairIds); + + /** + * 统计资产被其他未完结报修单占用的次数 + * + * @param assetId 资产ID + * @param currentRepairId 当前报修单ID(排除自身) + * @param statuses 未完结的报修单状态列表 + * @return 占用次数 + */ + public int countOtherActiveRepairOrderByAssetId(@Param("assetId") Long assetId, + @Param("currentRepairId") Long currentRepairId, + @Param("statuses") List statuses); + + /** + * 查询可用于报修的在库/在用资产列表 + * + * @param asset 过滤条件 + * @param currentRepairId 当前单据ID(排除自身) + * @param statuses 允许的前置资产状态(IN_STOCK, IN_USE) + * @param activeRepairStatuses 活跃报修状态列表 + * @return 可报修资产列表 + */ + public List selectAvailableRepairAssetList(@Param("asset") AmsAsset asset, + @Param("currentRepairId") Long currentRepairId, + @Param("statuses") List statuses, + @Param("activeRepairStatuses") List activeRepairStatuses); +} diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/service/IAmsRepairOrderService.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/service/IAmsRepairOrderService.java new file mode 100644 index 0000000..8d5333d --- /dev/null +++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/service/IAmsRepairOrderService.java @@ -0,0 +1,119 @@ +package com.ruoyi.asset.service; + +import java.util.List; +import com.ruoyi.asset.domain.AmsRepairOperateContext; +import com.ruoyi.asset.domain.AmsRepairOrder; + +/** + * 报修维修管理Service接口 + * + * @author Yangk + * @date 2026-06-16 + */ +public interface IAmsRepairOrderService +{ + /** + * 查询报修维修管理 + * + * @param repairId 报修维修管理主键 + * @return 报修维修管理 + */ + public AmsRepairOrder selectAmsRepairOrderByRepairId(Long repairId); + + /** + * 查询报修维修管理列表 + * + * @param amsRepairOrder 报修维修管理 + * @return 报修维修管理集合 + */ + public List selectAmsRepairOrderList(AmsRepairOrder amsRepairOrder); + + /** + * 新增报修维修管理 + * + * @param amsRepairOrder 报修维修管理 + * @param operateContext 操作上下文 + * @return 结果 + */ + public int insertAmsRepairOrder(AmsRepairOrder amsRepairOrder, AmsRepairOperateContext operateContext); + + /** + * 修改报修维修管理 + * + * @param amsRepairOrder 报修维修管理 + * @param operateContext 操作上下文 + * @return 结果 + */ + public int updateAmsRepairOrder(AmsRepairOrder amsRepairOrder, AmsRepairOperateContext operateContext); + + /** + * 批量删除报修维修管理 + * + * @param repairIds 需要删除的报修维修管理主键集合 + * @return 结果 + */ + public int deleteAmsRepairOrderByRepairIds(String repairIds); + + /** + * 删除报修维修管理信息 + * + * @param repairId 报修维修管理主键 + * @return 结果 + */ + public int deleteAmsRepairOrderByRepairId(Long repairId); + + /** + * 提交报修申请 + * + * @param repairId 报修维修管理ID + * @param operateContext 操作上下文 + * @return 结果 + */ + public int submitRepair(Long repairId, AmsRepairOperateContext operateContext); + + /** + * 确认受理报修 + * + * @param repairId 报修维修管理ID + * @param operateContext 操作上下文 + * @return 结果 + */ + public int confirmRepair(Long repairId, AmsRepairOperateContext operateContext); + + /** + * 驳回报修申请 + * + * @param repairId 报修维修管理ID + * @param rejectReason 驳回原因 + * @param operateContext 操作上下文 + * @return 结果 + */ + public int rejectRepair(Long repairId, String rejectReason, AmsRepairOperateContext operateContext); + + /** + * 开始维修 + * + * @param amsRepairOrder 包含维修方信息的实体 + * @param operateContext 操作上下文 + * @return 结果 + */ + public int startRepair(AmsRepairOrder amsRepairOrder, AmsRepairOperateContext operateContext); + + /** + * 完成维修 + * + * @param amsRepairOrder 包含维修费用、结果的实体 + * @param operateContext 操作上下文 + * @return 结果 + */ + public int finishRepair(AmsRepairOrder amsRepairOrder, AmsRepairOperateContext operateContext); + + /** + * 查询可用于报修的在库/在用资产列表 + * + * @param asset 过滤条件 + * @param currentRepairId 当前报修单ID(排除自身) + * @return 可报修资产列表 + */ + public List selectAvailableRepairAssetList(com.ruoyi.asset.domain.AmsAsset asset, Long currentRepairId); +} diff --git a/ruoyi-asset/src/main/java/com/ruoyi/asset/service/impl/AmsRepairOrderServiceImpl.java b/ruoyi-asset/src/main/java/com/ruoyi/asset/service/impl/AmsRepairOrderServiceImpl.java new file mode 100644 index 0000000..52dc78f --- /dev/null +++ b/ruoyi-asset/src/main/java/com/ruoyi/asset/service/impl/AmsRepairOrderServiceImpl.java @@ -0,0 +1,602 @@ +package com.ruoyi.asset.service.impl; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.asset.domain.AmsAsset; +import com.ruoyi.asset.domain.AmsRepairOperateContext; +import com.ruoyi.asset.domain.AmsRepairOrder; +import com.ruoyi.asset.domain.AssetTransitionContext; +import com.ruoyi.asset.domain.AssetTransitionResult; +import com.ruoyi.asset.mapper.AmsAssetMapper; +import com.ruoyi.asset.mapper.AmsRepairOrderMapper; +import com.ruoyi.asset.service.IAmsRepairOrderService; +import com.ruoyi.asset.service.IAssetStatusTransitionService; +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; + +/** + * 报修维修管理Service业务层处理 + * + * 核心职责:处理实物资产从报修(在库/在用状态)到进入维修中(REPAIRING), + * 并在维修完成后将状态和归属完整恢复至原状态(在库/在用)的生命周期。 + * + * @author Yangk + * @date 2026-06-16 + */ +@Service +public class AmsRepairOrderServiceImpl implements IAmsRepairOrderService +{ + private static final String REPAIR_ORDER_RULE = "REPAIR_ORDER"; + private static final String DEL_FLAG_NORMAL = "0"; + + // 资产状态 + private static final String ASSET_STATUS_IN_STOCK = "IN_STOCK"; + private static final String ASSET_STATUS_IN_USE = "IN_USE"; + + // 报修单状态 + private static final String STATUS_DRAFT = "DRAFT"; + private static final String STATUS_PENDING_CONFIRM = "PENDING_CONFIRM"; + private static final String STATUS_REJECTED = "REJECTED"; + private static final String STATUS_WAIT_REPAIR = "WAIT_REPAIR"; + private static final String STATUS_REPAIRING = "REPAIRING"; + private static final String STATUS_REPAIR_DONE = "REPAIR_DONE"; + + // 维修方类型 + private static final String REPAIRER_TYPE_INTERNAL = "INTERNAL"; + private static final String REPAIRER_TYPE_EXTERNAL = "EXTERNAL"; + + @Autowired + private AmsRepairOrderMapper amsRepairOrderMapper; + + @Autowired + private AmsAssetMapper amsAssetMapper; + + @Autowired + private ISysCodeRuleService sysCodeRuleService; + + @Autowired + private IAssetStatusTransitionService assetStatusTransitionService; + + /** + * 查询报修维修管理 + */ + @Override + public AmsRepairOrder selectAmsRepairOrderByRepairId(Long repairId) + { + return amsRepairOrderMapper.selectAmsRepairOrderByRepairId(repairId); + } + + /** + * 查询报修维修管理列表 + */ + @Override + public List selectAmsRepairOrderList(AmsRepairOrder amsRepairOrder) + { + return amsRepairOrderMapper.selectAmsRepairOrderList(amsRepairOrder); + } + + /** + * 新增报修单草稿 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int insertAmsRepairOrder(AmsRepairOrder amsRepairOrder, AmsRepairOperateContext operateContext) + { + validateOperateContext(operateContext); + + // 1. 基础报文非空和长度校验 + validateOrderRequest(amsRepairOrder); + + // 2. 悲观锁锁定资产,校验原状态,并回填属性快照及前置状态 + fillAssetSnapshots(amsRepairOrder, null); + + // 3. 生成 BX 前缀的唯一报修单号 + Date now = DateUtils.getNowDate(); + amsRepairOrder.setRepairNo(sysCodeRuleService.nextCode(REPAIR_ORDER_RULE)); + amsRepairOrder.setReportUserId(operateContext.getOperateUserId()); + amsRepairOrder.setReportUserName(StringUtils.trim(operateContext.getOperateUserName())); + amsRepairOrder.setReportTime(now); + amsRepairOrder.setOrderStatus(STATUS_DRAFT); + amsRepairOrder.setDelFlag(DEL_FLAG_NORMAL); + amsRepairOrder.setCreateBy(operateContext.getOperateLoginName()); + amsRepairOrder.setCreateTime(now); + + // 重置受控的维修中及维修完成字段,以防篡改 + amsRepairOrder.setRepairerType(REPAIRER_TYPE_INTERNAL); + amsRepairOrder.setRepairUserId(null); + amsRepairOrder.setRepairUserName(null); + amsRepairOrder.setRepairOrgName(null); + amsRepairOrder.setRepairContactPhone(null); + amsRepairOrder.setRepairStartTime(null); + amsRepairOrder.setRepairFinishTime(null); + amsRepairOrder.setRepairResult(null); + amsRepairOrder.setRepairCost(BigDecimal.ZERO); + amsRepairOrder.setBeforeAssetStatus(null); + amsRepairOrder.setRejectReason(null); + + return amsRepairOrderMapper.insertAmsRepairOrder(amsRepairOrder); + } + + /** + * 修改报修单草稿 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int updateAmsRepairOrder(AmsRepairOrder amsRepairOrder, AmsRepairOperateContext operateContext) + { + validateOperateContext(operateContext); + if (amsRepairOrder == null || amsRepairOrder.getRepairId() == null) + { + throw new ServiceException("单据ID不能为空"); + } + + // 1. 悲观锁锁定并校验状态必须为 DRAFT。非草稿状态严禁修改 + AmsRepairOrder current = requireOrderForUpdate(amsRepairOrder.getRepairId(), STATUS_DRAFT, + "仅草稿状态的报修单允许修改"); + + validateOrderRequest(amsRepairOrder); + + // 2. 锁定资产,校验原状态并重新回填快照 + fillAssetSnapshots(amsRepairOrder, amsRepairOrder.getRepairId()); + + // 3. 强行还原单号等受控属性,清空受理及驳回信息,强制复归为 DRAFT + amsRepairOrder.setRepairNo(current.getRepairNo()); + amsRepairOrder.setReportUserId(current.getReportUserId()); + amsRepairOrder.setReportUserName(current.getReportUserName()); + amsRepairOrder.setReportTime(current.getReportTime()); + amsRepairOrder.setOrderStatus(STATUS_DRAFT); + amsRepairOrder.setCreateBy(current.getCreateBy()); + amsRepairOrder.setCreateTime(current.getCreateTime()); + amsRepairOrder.setDelFlag(current.getDelFlag()); + amsRepairOrder.setUpdateBy(operateContext.getOperateLoginName()); + amsRepairOrder.setUpdateTime(DateUtils.getNowDate()); + + // 重置受控字段 + amsRepairOrder.setRepairerType(StringUtils.isEmpty(current.getRepairerType()) + ? REPAIRER_TYPE_INTERNAL : current.getRepairerType()); + amsRepairOrder.setRepairUserId(null); + amsRepairOrder.setRepairUserName(null); + amsRepairOrder.setRepairOrgName(null); + amsRepairOrder.setRepairContactPhone(null); + amsRepairOrder.setRepairStartTime(null); + amsRepairOrder.setRepairFinishTime(null); + amsRepairOrder.setRepairResult(null); + amsRepairOrder.setRepairCost(BigDecimal.ZERO); + amsRepairOrder.setBeforeAssetStatus(null); + amsRepairOrder.setRejectReason(null); + + return updateOrderOrThrow(amsRepairOrder, STATUS_DRAFT, "单据状态已发生改变,更新失败"); + } + + /** + * 批量逻辑删除报修单草稿 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteAmsRepairOrderByRepairIds(String repairIds) + { + // 1. 将操作ID排序以彻底防范死锁 + Long[] sortedIds = Arrays.stream(Convert.toStrArray(repairIds)) + .map(Long::valueOf).sorted().toArray(Long[]::new); + int rows = 0; + for (Long repairId : sortedIds) + { + // 2. 获取排他锁,验证是否为 DRAFT 草稿单 + AmsRepairOrder order = amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(repairId); + if (order == null) + { + continue; + } + if (!STATUS_DRAFT.equals(order.getOrderStatus())) + { + throw new ServiceException(StringUtils.format("报修单【{}】非草稿状态,不允许删除", order.getRepairNo())); + } + rows += amsRepairOrderMapper.deleteAmsRepairOrderByRepairId(repairId); + } + return rows; + } + + /** + * 逻辑删除单个报修单草稿 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteAmsRepairOrderByRepairId(Long repairId) + { + AmsRepairOrder order = amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(repairId); + if (order == null) + { + return 0; + } + if (!STATUS_DRAFT.equals(order.getOrderStatus())) + { + throw new ServiceException(StringUtils.format("报修单【{}】非草稿状态,不允许删除", order.getRepairNo())); + } + return amsRepairOrderMapper.deleteAmsRepairOrderByRepairId(repairId); + } + + /** + * 提交报修申请 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int submitRepair(Long repairId, AmsRepairOperateContext operateContext) + { + validateOperateContext(operateContext); + AmsRepairOrder order = requireOrderForUpdate(repairId, STATUS_DRAFT, "仅草稿状态的报修单允许提交"); + + // 二次校验实物状态与占用情况 + validateAssetReadyForRepair(order.getAssetId(), order.getRepairId()); + + order.setOrderStatus(STATUS_PENDING_CONFIRM); + order.setUpdateBy(operateContext.getOperateLoginName()); + order.setUpdateTime(DateUtils.getNowDate()); + + return updateOrderOrThrow(order, STATUS_DRAFT, "报修单提交失败,请刷新后重试"); + } + + /** + * 确认受理报修 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int confirmRepair(Long repairId, AmsRepairOperateContext operateContext) + { + validateOperateContext(operateContext); + AmsRepairOrder order = requireOrderForUpdate(repairId, STATUS_PENDING_CONFIRM, "仅待确认状态的报修单允许受理"); + + // 二次校验实物状态与占用情况 + validateAssetReadyForRepair(order.getAssetId(), order.getRepairId()); + + order.setOrderStatus(STATUS_WAIT_REPAIR); + order.setUpdateBy(operateContext.getOperateLoginName()); + order.setUpdateTime(DateUtils.getNowDate()); + order.setRejectReason(null); // 清除可能存在的驳回记录 + + return updateOrderOrThrow(order, STATUS_PENDING_CONFIRM, "报修单受理失败,请刷新后重试"); + } + + /** + * 驳回报修申请 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int rejectRepair(Long repairId, String rejectReason, AmsRepairOperateContext operateContext) + { + validateOperateContext(operateContext); + AmsRepairOrder order = requireOrderForUpdate(repairId, STATUS_PENDING_CONFIRM, "仅待确认状态的报修单允许驳回"); + if (StringUtils.isEmpty(StringUtils.trim(rejectReason))) + { + throw new ServiceException("驳回原因不能为空"); + } + validateLength(rejectReason, 500, "驳回原因"); + + order.setOrderStatus(STATUS_REJECTED); + order.setRejectReason(StringUtils.trim(rejectReason)); + order.setUpdateBy(operateContext.getOperateLoginName()); + order.setUpdateTime(DateUtils.getNowDate()); + + return updateOrderOrThrow(order, STATUS_PENDING_CONFIRM, "报修单驳回失败,请刷新后重试"); + } + + /** + * 开始维修:WAIT_REPAIR → REPAIRING + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int startRepair(AmsRepairOrder amsRepairOrder, AmsRepairOperateContext operateContext) + { + validateOperateContext(operateContext); + if (amsRepairOrder == null || amsRepairOrder.getRepairId() == null) + { + throw new ServiceException("参数不能为空"); + } + + // 1. 锁定单据并核查必须处于 WAIT_REPAIR 状态 + AmsRepairOrder order = requireOrderForUpdate(amsRepairOrder.getRepairId(), STATUS_WAIT_REPAIR, + "仅待维修状态的报修单允许开始维修"); + + // 2. 校验维修方信息并填充 + if (StringUtils.isEmpty(amsRepairOrder.getRepairerType())) + { + throw new ServiceException("维修方类型不能为空"); + } + if (!REPAIRER_TYPE_INTERNAL.equals(amsRepairOrder.getRepairerType()) + && !REPAIRER_TYPE_EXTERNAL.equals(amsRepairOrder.getRepairerType())) + { + throw new ServiceException("非法的维修方类型"); + } + + if (REPAIRER_TYPE_INTERNAL.equals(amsRepairOrder.getRepairerType())) + { + if (StringUtils.isEmpty(amsRepairOrder.getRepairUserName())) + { + throw new ServiceException("内部维修人不能为空"); + } + order.setRepairUserId(amsRepairOrder.getRepairUserId()); + order.setRepairUserName(StringUtils.trim(amsRepairOrder.getRepairUserName())); + order.setRepairOrgName(null); + } + else + { + if (StringUtils.isEmpty(amsRepairOrder.getRepairOrgName())) + { + throw new ServiceException("外部维修单位不能为空"); + } + order.setRepairUserId(null); + order.setRepairUserName(StringUtils.trim(amsRepairOrder.getRepairUserName())); // 允许外修时选记录人 + order.setRepairOrgName(StringUtils.trim(amsRepairOrder.getRepairOrgName())); + } + + validateLength(amsRepairOrder.getRepairContactPhone(), 30, "联系电话"); + order.setRepairerType(amsRepairOrder.getRepairerType()); + order.setRepairContactPhone(StringUtils.trim(amsRepairOrder.getRepairContactPhone())); + + // 3. 悲观锁定资产,并校验其前置实物状态 + AmsAsset asset = amsAssetMapper.selectAmsAssetByAssetIdForUpdate(order.getAssetId()); + if (asset == null || "1".equals(asset.getDelFlag())) + { + throw new ServiceException("资产不存在或已删除"); + } + if (!ASSET_STATUS_IN_STOCK.equals(asset.getAssetStatus()) + && !ASSET_STATUS_IN_USE.equals(asset.getAssetStatus())) + { + throw new ServiceException(StringUtils.format("资产【{}】当前状态【{}】不允许进行维修", + asset.getAssetCode(), asset.getAssetStatus())); + } + + // 4. 调用公共流转服务执行开始维修 (实物变为 REPAIRING 并写入履历) + AssetTransitionContext context = buildTransitionContext(order, operateContext, "开始资产维修"); + + AssetTransitionResult transitionResult = assetStatusTransitionService.startRepair(order.getAssetId(), context); + + // 5. 记录维修前的资产状态快照,用于恢复 + order.setBeforeAssetStatus(transitionResult.getBeforeAsset().getAssetStatus()); + order.setRepairStartTime(DateUtils.getNowDate()); + order.setOrderStatus(STATUS_REPAIRING); + order.setUpdateBy(operateContext.getOperateLoginName()); + order.setUpdateTime(DateUtils.getNowDate()); + + return updateOrderOrThrow(order, STATUS_WAIT_REPAIR, "报修单开始维修失败,请刷新后重试"); + } + + /** + * 完成维修:REPAIRING → REPAIR_DONE + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int finishRepair(AmsRepairOrder amsRepairOrder, AmsRepairOperateContext operateContext) + { + validateOperateContext(operateContext); + if (amsRepairOrder == null || amsRepairOrder.getRepairId() == null) + { + throw new ServiceException("参数不能为空"); + } + + // 1. 锁定单据并核查必须处于 REPAIRING 状态 + AmsRepairOrder order = requireOrderForUpdate(amsRepairOrder.getRepairId(), STATUS_REPAIRING, + "仅维修中状态的报修单允许提交完成"); + + // 2. 校验完成信息 + if (StringUtils.isEmpty(StringUtils.trim(amsRepairOrder.getRepairResult()))) + { + throw new ServiceException("维修结果不能为空"); + } + validateLength(amsRepairOrder.getRepairResult(), 500, "维修结果"); + + if (amsRepairOrder.getRepairCost() == null || amsRepairOrder.getRepairCost().compareTo(BigDecimal.ZERO) < 0) + { + throw new ServiceException("维修费用不能为空且必须为非负数"); + } + if (amsRepairOrder.getRepairFinishTime() == null) + { + throw new ServiceException("维修完成时间不能为空"); + } + if (order.getRepairStartTime() != null && amsRepairOrder.getRepairFinishTime().before(order.getRepairStartTime())) + { + throw new ServiceException("维修完成时间不能早于维修开始时间"); + } + + // 3. 悲观锁锁定资产 + AmsAsset asset = amsAssetMapper.selectAmsAssetByAssetIdForUpdate(order.getAssetId()); + if (asset == null || "1".equals(asset.getDelFlag())) + { + throw new ServiceException("资产不存在或已删除"); + } + + // 4. 调用公共流转服务执行完成维修 (资产恢复至维修前状态并写履历) + AssetTransitionContext context = buildTransitionContext(order, operateContext, "完成资产维修"); + + assetStatusTransitionService.finishRepair(order.getAssetId(), order.getBeforeAssetStatus(), context); + + // 5. 填充完成属性 + order.setRepairFinishTime(amsRepairOrder.getRepairFinishTime()); + order.setRepairResult(StringUtils.trim(amsRepairOrder.getRepairResult())); + order.setRepairCost(amsRepairOrder.getRepairCost()); + order.setOrderStatus(STATUS_REPAIR_DONE); + order.setUpdateBy(operateContext.getOperateLoginName()); + order.setUpdateTime(DateUtils.getNowDate()); + + return updateOrderOrThrow(order, STATUS_REPAIRING, "报修单完成维修失败,请刷新后重试"); + } + + /** + * 查询可用于报修的在库/在用资产列表 + */ + @Override + public List selectAvailableRepairAssetList(AmsAsset asset, Long currentRepairId) + { + return amsRepairOrderMapper.selectAvailableRepairAssetList(asset, currentRepairId, + Arrays.asList(ASSET_STATUS_IN_STOCK, ASSET_STATUS_IN_USE), + Arrays.asList(STATUS_DRAFT, STATUS_PENDING_CONFIRM, STATUS_WAIT_REPAIR, STATUS_REPAIRING)); + } + + // ========================================== 私有辅助方法 ========================================== + + private AmsRepairOrder requireOrderForUpdate(Long repairId, String expectedStatus, String errMsg) + { + AmsRepairOrder order = amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(repairId); + if (order == null) + { + throw new ServiceException("报修单不存在或已删除"); + } + if (!expectedStatus.equals(order.getOrderStatus())) + { + throw new ServiceException(errMsg); + } + return order; + } + + private int updateOrderOrThrow(AmsRepairOrder order, String expectedStatus, String errorMessage) + { + // 即使当前事务已用 for update 加锁,仍写入期望状态,防止未来绕过锁读路径时误用通用更新 SQL。 + order.getParams().put("expectedOrderStatus", expectedStatus); + if (amsRepairOrderMapper.updateAmsRepairOrder(order) != 1) + { + throw new ServiceException(errorMessage); + } + return 1; + } + + private AssetTransitionContext buildTransitionContext(AmsRepairOrder order, AmsRepairOperateContext operateContext, + String changeSummary) + { + AssetTransitionContext context = new AssetTransitionContext(); + context.setSourceOrderId(order.getRepairId()); + context.setSourceOrderNo(order.getRepairNo()); + context.setOperateUserId(operateContext.getOperateUserId()); + context.setOperateUserName(StringUtils.trim(operateContext.getOperateUserName())); + context.setOperateLoginName(operateContext.getOperateLoginName()); + context.setChangeSummary(changeSummary); + context.setRemark(order.getRemark()); + return context; + } + + private void validateOrderRequest(AmsRepairOrder order) + { + if (order == null) + { + throw new ServiceException("单据数据不能为空"); + } + if (order.getAssetId() == null) + { + throw new ServiceException("报修的资产不能为空"); + } + if (StringUtils.isEmpty(StringUtils.trim(order.getFaultDesc()))) + { + throw new ServiceException("故障描述不能为空"); + } + validateLength(order.getFaultDesc(), 500, "故障描述"); + validateLength(order.getRemark(), 500, "备注"); + if (order.getExpectedFinishTime() != null) + { + Date todayZero = DateUtils.truncate(DateUtils.getNowDate(), java.util.Calendar.DATE); + if (order.getExpectedFinishTime().before(todayZero)) + { + throw new ServiceException("期望完成日期不能早于当前日期"); + } + } + } + + private void fillAssetSnapshots(AmsRepairOrder order, Long currentRepairId) + { + AmsAsset asset = amsAssetMapper.selectAmsAssetByAssetIdForUpdate(order.getAssetId()); + if (asset == null || "1".equals(asset.getDelFlag())) + { + throw new ServiceException("所选择的报修资产不存在或已删除"); + } + + // 1. 实物前置状态必须为在库或在用 + if (!ASSET_STATUS_IN_STOCK.equals(asset.getAssetStatus()) + && !ASSET_STATUS_IN_USE.equals(asset.getAssetStatus())) + { + throw new ServiceException(StringUtils.format("资产【{}】当前状态【{}】不允许进行报修", + asset.getAssetCode(), asset.getAssetStatus())); + } + + // 2. 校验并发占用 + int occupied = amsRepairOrderMapper.countOtherActiveRepairOrderByAssetId(order.getAssetId(), currentRepairId, + Arrays.asList(STATUS_DRAFT, STATUS_PENDING_CONFIRM, STATUS_WAIT_REPAIR, STATUS_REPAIRING)); + if (occupied > 0) + { + throw new ServiceException(StringUtils.format("资产【{}】当前已被其他未完结报修单占用", asset.getAssetCode())); + } + + // 3. 回填基本属性与仓位/部门快照 + order.setAssetCode(asset.getAssetCode()); + order.setAssetName(asset.getAssetName()); + order.setCategoryId(asset.getCategoryId()); + order.setCategoryCode(asset.getCategoryCode()); + order.setCategoryName(asset.getCategoryName()); + order.setSpecModel(asset.getSpecModel()); + order.setBrand(asset.getBrand()); + } + + private void validateAssetReadyForRepair(Long assetId, Long currentRepairId) + { + AmsAsset asset = amsAssetMapper.selectAmsAssetByAssetIdForUpdate(assetId); + if (asset == null || "1".equals(asset.getDelFlag())) + { + throw new ServiceException("资产不存在或已删除"); + } + if (!ASSET_STATUS_IN_STOCK.equals(asset.getAssetStatus()) + && !ASSET_STATUS_IN_USE.equals(asset.getAssetStatus())) + { + throw new ServiceException(StringUtils.format("资产【{}】当前状态不能借由流程流转", asset.getAssetCode())); + } + int occupied = amsRepairOrderMapper.countOtherActiveRepairOrderByAssetId(assetId, currentRepairId, + Arrays.asList(STATUS_DRAFT, STATUS_PENDING_CONFIRM, STATUS_WAIT_REPAIR, STATUS_REPAIRING)); + if (occupied > 0) + { + throw new ServiceException(StringUtils.format("资产【{}】已被其他未完成报修单占用", asset.getAssetCode())); + } + } + + private void validateLength(String value, int maxLength, String fieldName) + { + if (StringUtils.isNotEmpty(value) && value.length() > maxLength) + { + throw new ServiceException(fieldName + "长度不能超过" + maxLength + "个字符"); + } + } + + private void validateLoginName(String operateLoginName) + { + if (StringUtils.isEmpty(operateLoginName)) + { + throw new ServiceException("操作人账号不能为空"); + } + validateLength(operateLoginName, 64, "操作账号"); + } + + private void validateOperateContext(AmsRepairOperateContext operateContext) + { + if (operateContext == null) + { + throw new ServiceException("操作上下文不能为空"); + } + validateOperator(operateContext.getOperateUserId(), operateContext.getOperateUserName(), "操作人"); + validateLoginName(operateContext.getOperateLoginName()); + } + + private void validateOperator(Long operateUserId, String operateUserName, String fieldPrefix) + { + if (operateUserId == null) + { + throw new ServiceException(fieldPrefix + "ID不能为空"); + } + if (StringUtils.isEmpty(StringUtils.trim(operateUserName))) + { + throw new ServiceException(fieldPrefix + "名称不能为空"); + } + validateLength(operateUserName, 100, fieldPrefix + "名称"); + } +} diff --git a/ruoyi-asset/src/main/resources/mapper/asset/AmsRepairOrderMapper.xml b/ruoyi-asset/src/main/resources/mapper/asset/AmsRepairOrderMapper.xml new file mode 100644 index 0000000..e3fe8a8 --- /dev/null +++ b/ruoyi-asset/src/main/resources/mapper/asset/AmsRepairOrderMapper.xml @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select repair_id, repair_no, asset_id, asset_code, asset_name, category_id, category_code, category_name, spec_model, brand, report_user_id, report_user_name, fault_desc, report_time, expected_finish_time, repairer_type, repair_user_id, repair_user_name, repair_org_name, repair_contact_phone, repair_start_time, repair_finish_time, repair_result, repair_cost, before_asset_status, reject_reason, order_status, create_by, create_time, update_by, update_time, remark, del_flag from ams_repair_order + + + + + + + + + + insert into ams_repair_order + + repair_no, + asset_id, + asset_code, + asset_name, + category_id, + category_code, + category_name, + spec_model, + brand, + report_user_id, + report_user_name, + fault_desc, + report_time, + expected_finish_time, + repairer_type, + repair_user_id, + repair_user_name, + repair_org_name, + repair_contact_phone, + repair_start_time, + repair_finish_time, + repair_result, + repair_cost, + before_asset_status, + reject_reason, + order_status, + create_by, + create_time, + update_by, + update_time, + remark, + del_flag, + + + #{repairNo}, + #{assetId}, + #{assetCode}, + #{assetName}, + #{categoryId}, + #{categoryCode}, + #{categoryName}, + #{specModel}, + #{brand}, + #{reportUserId}, + #{reportUserName}, + #{faultDesc}, + #{reportTime}, + #{expectedFinishTime}, + #{repairerType}, + #{repairUserId}, + #{repairUserName}, + #{repairOrgName}, + #{repairContactPhone}, + #{repairStartTime}, + #{repairFinishTime}, + #{repairResult}, + #{repairCost}, + #{beforeAssetStatus}, + #{rejectReason}, + #{orderStatus}, + #{createBy}, + #{createTime}, + #{updateBy}, + #{updateTime}, + #{remark}, + #{delFlag}, + + + + + update ams_repair_order + set repair_no = #{repairNo}, + asset_id = #{assetId}, + asset_code = #{assetCode}, + asset_name = #{assetName}, + category_id = #{categoryId}, + category_code = #{categoryCode}, + category_name = #{categoryName}, + spec_model = #{specModel}, + brand = #{brand}, + report_user_id = #{reportUserId}, + report_user_name = #{reportUserName}, + fault_desc = #{faultDesc}, + report_time = #{reportTime}, + expected_finish_time = #{expectedFinishTime}, + repairer_type = #{repairerType}, + repair_user_id = #{repairUserId}, + repair_user_name = #{repairUserName}, + repair_org_name = #{repairOrgName}, + repair_contact_phone = #{repairContactPhone}, + repair_start_time = #{repairStartTime}, + repair_finish_time = #{repairFinishTime}, + repair_result = #{repairResult}, + repair_cost = #{repairCost}, + before_asset_status = #{beforeAssetStatus}, + reject_reason = #{rejectReason}, + order_status = #{orderStatus}, + create_by = #{createBy}, + create_time = #{createTime}, + update_by = #{updateBy}, + update_time = #{updateTime}, + remark = #{remark}, + del_flag = #{delFlag} + where repair_id = #{repairId} and del_flag = '0' + + and order_status = #{params.expectedOrderStatus} + + + + + + update ams_repair_order set del_flag = '1' where repair_id = #{repairId} and del_flag = '0' and order_status = 'DRAFT' + + + + update ams_repair_order set del_flag = '1' where repair_id in + + #{repairId} + + and del_flag = '0' and order_status = 'DRAFT' + + + + + + diff --git a/ruoyi-asset/src/main/resources/templates/asset/repair/add.html b/ruoyi-asset/src/main/resources/templates/asset/repair/add.html new file mode 100644 index 0000000..e0c3a4b --- /dev/null +++ b/ruoyi-asset/src/main/resources/templates/asset/repair/add.html @@ -0,0 +1,174 @@ + + + + + + + +
+
+

基本信息

+
+
+
+ +
+
+ + + + + +
+
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+
+ + +
+
+
+
+
+ +
+
+
+ +
+ + +
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/ruoyi-asset/src/main/resources/templates/asset/repair/edit.html b/ruoyi-asset/src/main/resources/templates/asset/repair/edit.html new file mode 100644 index 0000000..c0bf4e5 --- /dev/null +++ b/ruoyi-asset/src/main/resources/templates/asset/repair/edit.html @@ -0,0 +1,187 @@ + + + + + + + +
+
+ +

基本信息

+
+
+
+ +
+ +
+
+
+
+
+ +
+
+ + + + + +
+
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+
+ + +
+
+
+
+
+
+ +
+ + +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/ruoyi-asset/src/main/resources/templates/asset/repair/finish.html b/ruoyi-asset/src/main/resources/templates/asset/repair/finish.html new file mode 100644 index 0000000..c505cd0 --- /dev/null +++ b/ruoyi-asset/src/main/resources/templates/asset/repair/finish.html @@ -0,0 +1,81 @@ + + + + + + + +
+
+ + + + +
+ +
+ +
+
+ +
+ +
+
+ + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+ + + + + diff --git a/ruoyi-asset/src/main/resources/templates/asset/repair/repair.html b/ruoyi-asset/src/main/resources/templates/asset/repair/repair.html new file mode 100644 index 0000000..dc4ade2 --- /dev/null +++ b/ruoyi-asset/src/main/resources/templates/asset/repair/repair.html @@ -0,0 +1,252 @@ + + + + + + +
+
+
+
+
+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + + - + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • +  搜索 +  重置 +
  • +
+
+
+
+ + +
+
+
+
+
+ + + + diff --git a/ruoyi-asset/src/main/resources/templates/asset/repair/selectAsset.html b/ruoyi-asset/src/main/resources/templates/asset/repair/selectAsset.html new file mode 100644 index 0000000..627abac --- /dev/null +++ b/ruoyi-asset/src/main/resources/templates/asset/repair/selectAsset.html @@ -0,0 +1,81 @@ + + + + + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + + diff --git a/ruoyi-asset/src/main/resources/templates/asset/repair/start.html b/ruoyi-asset/src/main/resources/templates/asset/repair/start.html new file mode 100644 index 0000000..7a3554f --- /dev/null +++ b/ruoyi-asset/src/main/resources/templates/asset/repair/start.html @@ -0,0 +1,130 @@ + + + + + + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ + + +
+
+ + + + + + +
+ +
+ +
+
+
+
+ + + + diff --git a/ruoyi-asset/src/main/resources/templates/asset/repair/view.html b/ruoyi-asset/src/main/resources/templates/asset/repair/view.html new file mode 100644 index 0000000..35644ec --- /dev/null +++ b/ruoyi-asset/src/main/resources/templates/asset/repair/view.html @@ -0,0 +1,244 @@ + + + + + + +
+
+

基本信息

+
+
+
+ +
+

+
+
+
+
+
+ +
+

+ 草稿 + 待受理 + 已驳回 + 待维修 + 维修中 + 维修完成 +

+
+
+
+
+ +
+
+
+ +
+

+
+
+
+
+
+ +
+

+
+
+
+
+ +
+
+
+ +
+

+
+
+
+
+
+ +
+

+
+
+
+
+ +
+
+
+ +
+

+
+
+
+
+
+ +
+

+ 在库 + 在用 + - +

+
+
+
+
+ +

故障及申报信息

+
+
+
+ +
+

+
+
+
+
+
+ +
+

+
+
+
+
+ +
+
+
+ +
+

+
+
+
+
+
+ +
+

+
+
+
+
+ +
+
+
+ +
+

+
+
+
+
+ +
+
+
+ +
+

+
+
+
+
+ + +
+

维修过程及结果

+
+
+
+ +
+

+
+
+
+
+
+ +
+

+
+
+
+
+ +
+
+
+ +
+

+
+
+
+
+
+ +
+

+
+
+
+
+ +
+
+
+ +
+

+
+
+
+
+
+ +
+

+
+
+
+
+ +
+
+
+ +
+

+
+
+
+
+ +
+
+
+ +
+

+
+
+
+
+
+
+
+ + + diff --git a/ruoyi-asset/src/test/java/com/ruoyi/asset/service/impl/AmsRepairOrderServiceImplTest.java b/ruoyi-asset/src/test/java/com/ruoyi/asset/service/impl/AmsRepairOrderServiceImplTest.java new file mode 100644 index 0000000..d938b98 --- /dev/null +++ b/ruoyi-asset/src/test/java/com/ruoyi/asset/service/impl/AmsRepairOrderServiceImplTest.java @@ -0,0 +1,492 @@ +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.assertNull; +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.ArgumentMatchers.eq; +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.math.BigDecimal; +import java.util.Calendar; +import java.util.Date; + +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; + +import com.ruoyi.asset.domain.AmsAsset; +import com.ruoyi.asset.domain.AmsRepairOperateContext; +import com.ruoyi.asset.domain.AmsRepairOrder; +import com.ruoyi.asset.domain.AssetTransitionContext; +import com.ruoyi.asset.domain.AssetTransitionResult; +import com.ruoyi.asset.mapper.AmsAssetMapper; +import com.ruoyi.asset.mapper.AmsRepairOrderMapper; +import com.ruoyi.asset.service.IAssetStatusTransitionService; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.system.service.ISysCodeRuleService; + +/** + * 报修维修管理服务层单元测试类 + * + * 重点校验报修单的新增、修改、占用拦截、驳回、开始维修、完成维修状态联动及恢复等全生命周期流转。 + * + * @author Yangk + * @date 2026-06-16 + */ +@ExtendWith(MockitoExtension.class) +class AmsRepairOrderServiceImplTest +{ + @Mock + private AmsRepairOrderMapper amsRepairOrderMapper; + + @Mock + private AmsAssetMapper amsAssetMapper; + + @Mock + private ISysCodeRuleService sysCodeRuleService; + + @Mock + private IAssetStatusTransitionService assetStatusTransitionService; + + @InjectMocks + private AmsRepairOrderServiceImpl service; + + /** + * 测试:新增报修单草稿并回填快照 + * 断言: + * 1. 自动生成以 BX 为前缀的报修单号。 + * 2. 状态扭转为 DRAFT。 + * 3. 能够自动快照资产的编码、名称、品牌等属性。 + * 4. 维修受控字段被安全初始化。 + */ + @Test + void insertShouldGenerateCodeAndFillSnapshots() + { + AmsRepairOrder order = buildRequest(); + order.setReportUserId(999L); + order.setReportUserName("前端篡改报修人"); + stubAsset("IN_STOCK"); + + when(sysCodeRuleService.nextCode("REPAIR_ORDER")).thenReturn("BX202606160001"); + when(amsRepairOrderMapper.countOtherActiveRepairOrderByAssetId(eq(1L), eq(null), anyList())) + .thenReturn(0); + + doAnswer(invocation -> { + AmsRepairOrder inserted = invocation.getArgument(0); + inserted.setRepairId(100L); + return 1; + }).when(amsRepairOrderMapper).insertAmsRepairOrder(any(AmsRepairOrder.class)); + + assertEquals(1, service.insertAmsRepairOrder(order, + operateContext(2L, "真实报修人", "admin"))); + + assertEquals("BX202606160001", order.getRepairNo()); + assertEquals("DRAFT", order.getOrderStatus()); + assertEquals(2L, order.getReportUserId()); + assertEquals("真实报修人", order.getReportUserName()); + assertNotNull(order.getReportTime()); + assertEquals("admin", order.getCreateBy()); + assertEquals("A001", order.getAssetCode()); + assertEquals("一号资产", order.getAssetName()); + assertEquals("CAT-001", order.getCategoryCode()); + assertEquals("品牌A", order.getBrand()); + assertEquals("型号X", order.getSpecModel()); + assertEquals(BigDecimal.ZERO, order.getRepairCost()); + assertNull(order.getRepairStartTime()); + assertNull(order.getRepairFinishTime()); + assertNull(order.getRejectReason()); + } + + /** + * 测试:新增草稿时资产已被其他报修单占用 + * 断言:抛出 ServiceException 异常,不写入数据库。 + */ + @Test + void insertShouldRejectIfAssetOccupied() + { + AmsRepairOrder order = buildRequest(); + stubAsset("IN_STOCK"); + + // 模拟资产已被其他报修单占用 + when(amsRepairOrderMapper.countOtherActiveRepairOrderByAssetId(eq(1L), eq(null), anyList())) + .thenReturn(1); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.insertAmsRepairOrder(order, operateContext(2L, "真实报修人", "admin"))); + + assertTrue(exception.getMessage().contains("已被其他未完结报修单占用")); + verify(amsRepairOrderMapper, never()).insertAmsRepairOrder(any(AmsRepairOrder.class)); + } + + /** + * 测试:修改报修单草稿 + * 断言:只能在 DRAFT 状态修改,覆盖前端提交的快照,强制保留 DRAFT 且清除流转字段。 + */ + @Test + void updateShouldKeepDraftAndOverwriteSnapshots() + { + AmsRepairOrder order = buildPersistedOrder("DRAFT"); + order.setFaultDesc("更新后的故障描述"); + order.setRemark("更新后的备注"); + order.setReportUserId(999L); + order.setReportUserName("前端篡改报修人"); + + AmsRepairOrder current = buildPersistedOrder("DRAFT"); + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(current); + stubAsset("IN_STOCK"); + when(amsRepairOrderMapper.countOtherActiveRepairOrderByAssetId(eq(1L), eq(100L), anyList())) + .thenReturn(0); + when(amsRepairOrderMapper.updateAmsRepairOrder(any(AmsRepairOrder.class))).thenReturn(1); + + assertEquals(1, service.updateAmsRepairOrder(order, adminContext())); + + assertEquals("DRAFT", order.getOrderStatus()); + assertEquals("admin", order.getUpdateBy()); + assertEquals("DRAFT", order.getParams().get("expectedOrderStatus")); + assertEquals("更新后的故障描述", order.getFaultDesc()); + assertEquals(2L, order.getReportUserId()); + assertEquals("张三报修人", order.getReportUserName()); + assertEquals("A001", order.getAssetCode()); // 强制回填覆盖 + assertNull(order.getRepairStartTime()); + assertNull(order.getRepairFinishTime()); + assertEquals(BigDecimal.ZERO, order.getRepairCost()); + } + + /** + * 测试:在非 DRAFT 状态下修改报修单 + * 断言:抛出 ServiceException,阻断修改。 + */ + @Test + void updateShouldRejectNonDraft() + { + AmsRepairOrder order = buildPersistedOrder("PENDING_CONFIRM"); + + AmsRepairOrder current = buildPersistedOrder("PENDING_CONFIRM"); + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(current); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.updateAmsRepairOrder(order, adminContext())); + + assertTrue(exception.getMessage().contains("仅草稿状态的报修单允许修改")); + verify(amsRepairOrderMapper, never()).updateAmsRepairOrder(any(AmsRepairOrder.class)); + } + + /** + * 测试:提交报修申请 + * 断言:单据从 DRAFT 流转至 PENDING_CONFIRM,且校验资产未被占用。 + */ + @Test + void submitShouldMoveDraftToPendingConfirm() + { + AmsRepairOrder order = buildPersistedOrder("DRAFT"); + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(order); + stubAsset("IN_STOCK"); + when(amsRepairOrderMapper.countOtherActiveRepairOrderByAssetId(eq(1L), eq(100L), anyList())) + .thenReturn(0); + when(amsRepairOrderMapper.updateAmsRepairOrder(order)).thenReturn(1); + + assertEquals(1, service.submitRepair(100L, adminContext())); + assertEquals("PENDING_CONFIRM", order.getOrderStatus()); + assertEquals("DRAFT", order.getParams().get("expectedOrderStatus")); + verify(amsRepairOrderMapper).updateAmsRepairOrder(order); + } + + /** + * 测试:确认受理报修 + * 断言:单据从 PENDING_CONFIRM 进入 WAIT_REPAIR 状态。 + */ + @Test + void confirmShouldMovePendingConfirmToWaitRepair() + { + AmsRepairOrder order = buildPersistedOrder("PENDING_CONFIRM"); + order.setRejectReason("上次驳回的原因"); + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(order); + stubAsset("IN_STOCK"); + when(amsRepairOrderMapper.countOtherActiveRepairOrderByAssetId(eq(1L), eq(100L), anyList())) + .thenReturn(0); + when(amsRepairOrderMapper.updateAmsRepairOrder(order)).thenReturn(1); + + assertEquals(1, service.confirmRepair(100L, adminContext())); + assertEquals("WAIT_REPAIR", order.getOrderStatus()); + assertEquals("PENDING_CONFIRM", order.getParams().get("expectedOrderStatus")); + assertNull(order.getRejectReason()); // 清空驳回记录 + verify(amsRepairOrderMapper).updateAmsRepairOrder(order); + } + + /** + * 测试:状态流转更新行数为 0 时快速失败 + * 断言:Service 抛出异常,避免 Controller 将 0 行更新误当作业务成功。 + */ + @Test + void confirmShouldThrowIfUpdateRowsZero() + { + AmsRepairOrder order = buildPersistedOrder("PENDING_CONFIRM"); + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(order); + stubAsset("IN_STOCK"); + when(amsRepairOrderMapper.countOtherActiveRepairOrderByAssetId(eq(1L), eq(100L), anyList())) + .thenReturn(0); + when(amsRepairOrderMapper.updateAmsRepairOrder(order)).thenReturn(0); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.confirmRepair(100L, adminContext())); + + assertTrue(exception.getMessage().contains("受理失败")); + } + + /** + * 测试:驳回报修申请 + * 断言:单据由 PENDING_CONFIRM 变为 REJECTED,并必须带有非空的驳回原因。 + */ + @Test + void rejectShouldSetStatusAndReason() + { + AmsRepairOrder order = buildPersistedOrder("PENDING_CONFIRM"); + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(order); + when(amsRepairOrderMapper.updateAmsRepairOrder(order)).thenReturn(1); + + assertEquals(1, service.rejectRepair(100L, "资产情况描述不清", adminContext())); + assertEquals("REJECTED", order.getOrderStatus()); + assertEquals("PENDING_CONFIRM", order.getParams().get("expectedOrderStatus")); + assertEquals("资产情况描述不清", order.getRejectReason()); + + // 测试空驳回原因拦截 + assertThrows(ServiceException.class, () -> service.rejectRepair(100L, "", adminContext())); + } + + /** + * 测试:开始维修 (内修模式) + * 断言: + * 1. 状态由 WAIT_REPAIR 变为 REPAIRING。 + * 2. 调用公共流转服务 `startRepair`,锁定并转移资产到维修中。 + * 3. 写入维修方信息(类型、人员姓名等)以及开始时间。 + * 4. 记录资产维修前状态快照 `beforeAssetStatus`。 + */ + @Test + void startRepairInternalShouldTransitionAssetAndSetStartTime() + { + AmsRepairOrder order = buildPersistedOrder("WAIT_REPAIR"); + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(order); + AmsAsset beforeAsset = stubAsset("IN_STOCK"); // 原本在库 + when(assetStatusTransitionService.startRepair(eq(1L), any(AssetTransitionContext.class))) + .thenReturn(new AssetTransitionResult(beforeAsset, buildAsset("REPAIRING"))); + when(amsRepairOrderMapper.updateAmsRepairOrder(order)).thenReturn(1); + + AmsRepairOrder startParams = new AmsRepairOrder(); + startParams.setRepairId(100L); + startParams.setRepairerType("INTERNAL"); + startParams.setRepairUserId(5L); + startParams.setRepairUserName("张三"); + startParams.setRepairContactPhone("13888888888"); + + assertEquals(1, service.startRepair(startParams, adminContext())); + + assertEquals("REPAIRING", order.getOrderStatus()); + assertEquals("INTERNAL", order.getRepairerType()); + assertEquals(5L, order.getRepairUserId()); + assertEquals("张三", order.getRepairUserName()); + assertNull(order.getRepairOrgName()); + assertEquals("13888888888", order.getRepairContactPhone()); + assertEquals("IN_STOCK", order.getBeforeAssetStatus()); // 快照原本在库状态 + assertEquals("WAIT_REPAIR", order.getParams().get("expectedOrderStatus")); + assertNotNull(order.getRepairStartTime()); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(AssetTransitionContext.class); + verify(assetStatusTransitionService).startRepair(eq(1L), contextCaptor.capture()); + assertEquals(100L, contextCaptor.getValue().getSourceOrderId()); + assertEquals("BX202606160001", contextCaptor.getValue().getSourceOrderNo()); + assertEquals(9L, contextCaptor.getValue().getOperateUserId()); + assertEquals("管理员", contextCaptor.getValue().getOperateUserName()); + } + + /** + * 测试:开始维修 (外修模式) + * 断言:校验外部维修单位不为空,正确写入外部单位名称。 + */ + @Test + void startRepairExternalShouldRequireOrgName() + { + AmsRepairOrder order = buildPersistedOrder("WAIT_REPAIR"); + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(order); + AmsAsset beforeAsset = stubAsset("IN_USE"); // 原本在用 + when(assetStatusTransitionService.startRepair(eq(1L), any(AssetTransitionContext.class))) + .thenReturn(new AssetTransitionResult(beforeAsset, buildAsset("REPAIRING"))); + when(amsRepairOrderMapper.updateAmsRepairOrder(order)).thenReturn(1); + + AmsRepairOrder startParams = new AmsRepairOrder(); + startParams.setRepairId(100L); + startParams.setRepairerType("EXTERNAL"); + startParams.setRepairOrgName("外部维修公司"); + startParams.setRepairUserName("李外部人"); + startParams.setRepairContactPhone("13999999999"); + + assertEquals(1, service.startRepair(startParams, adminContext())); + + assertEquals("REPAIRING", order.getOrderStatus()); + assertEquals("EXTERNAL", order.getRepairerType()); + assertNull(order.getRepairUserId()); + assertEquals("李外部人", order.getRepairUserName()); + assertEquals("外部维修公司", order.getRepairOrgName()); + assertEquals("IN_USE", order.getBeforeAssetStatus()); // 快照原本在用状态 + } + + /** + * 测试:完成维修 + * 断言: + * 1. 状态进入 REPAIR_DONE。 + * 2. 调用公共流转服务 `finishRepair`,利用 `beforeAssetStatus` 将资产恢复到原状态。 + * 3. 填入完成时间、非负费用和维修结果。 + */ + @Test + void finishRepairShouldCompleteOrderAndRestoreAssetStatus() + { + AmsRepairOrder order = buildPersistedOrder("REPAIRING"); + // 模拟原本是在用状态 + order.setBeforeAssetStatus("IN_USE"); + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.HOUR_OF_DAY, -2); + order.setRepairStartTime(cal.getTime()); // 开始时间设为两小时前 + + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(order); + AmsAsset beforeAsset = stubAsset("REPAIRING"); // 实物处于维修中 + when(assetStatusTransitionService.finishRepair(eq(1L), eq("IN_USE"), any(AssetTransitionContext.class))) + .thenReturn(new AssetTransitionResult(beforeAsset, buildAsset("IN_USE"))); + when(amsRepairOrderMapper.updateAmsRepairOrder(order)).thenReturn(1); + + AmsRepairOrder finishParams = new AmsRepairOrder(); + finishParams.setRepairId(100L); + finishParams.setRepairFinishTime(new Date()); + finishParams.setRepairCost(new BigDecimal("150.00")); + finishParams.setRepairResult("更换了电源,目前通电测试正常"); + + assertEquals(1, service.finishRepair(finishParams, adminContext())); + + assertEquals("REPAIR_DONE", order.getOrderStatus()); + assertEquals("REPAIRING", order.getParams().get("expectedOrderStatus")); + assertEquals(new BigDecimal("150.00"), order.getRepairCost()); + assertEquals("更换了电源,目前通电测试正常", order.getRepairResult()); + assertNotNull(order.getRepairFinishTime()); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(AssetTransitionContext.class); + // 验证流转服务被调用,且传入了维修前原状态 IN_USE + verify(assetStatusTransitionService).finishRepair(eq(1L), eq("IN_USE"), contextCaptor.capture()); + assertEquals(100L, contextCaptor.getValue().getSourceOrderId()); + assertEquals(9L, contextCaptor.getValue().getOperateUserId()); + assertEquals("管理员", contextCaptor.getValue().getOperateUserName()); + } + + /** + * 测试:完成时间早于开始时间拦截 + */ + @Test + void finishRepairShouldRejectEarlyFinishTime() + { + AmsRepairOrder order = buildPersistedOrder("REPAIRING"); + order.setRepairStartTime(new Date()); + + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(order); + + AmsRepairOrder finishParams = new AmsRepairOrder(); + finishParams.setRepairId(100L); + // 完成时间设为昨天(早于开始时间) + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DAY_OF_YEAR, -1); + finishParams.setRepairFinishTime(cal.getTime()); + finishParams.setRepairCost(new BigDecimal("50.00")); + finishParams.setRepairResult("测试"); + + assertThrows(ServiceException.class, () -> service.finishRepair(finishParams, adminContext())); + } + + /** + * 测试:批量/单个逻辑删除报修单草稿 + * 断言:仅限 DRAFT 状态删除,否则抛出异常。 + */ + @Test + void deleteShouldOnlyAllowDraft() + { + // 1. 删除草稿单,验证成功 + AmsRepairOrder order1 = buildPersistedOrder("DRAFT"); + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(100L)).thenReturn(order1); + when(amsRepairOrderMapper.deleteAmsRepairOrderByRepairId(100L)).thenReturn(1); + + assertEquals(1, service.deleteAmsRepairOrderByRepairId(100L)); + + // 2. 删除非草稿单,验证抛出异常被拦截 + AmsRepairOrder order2 = buildPersistedOrder("REPAIRING"); + when(amsRepairOrderMapper.selectAmsRepairOrderByRepairIdForUpdate(200L)).thenReturn(order2); + + assertThrows(ServiceException.class, () -> service.deleteAmsRepairOrderByRepairId(200L)); + verify(amsRepairOrderMapper, never()).deleteAmsRepairOrderByRepairId(200L); + } + + // ========================================== 辅助方法 ========================================== + + private AmsRepairOrder buildRequest() + { + AmsRepairOrder order = new AmsRepairOrder(); + order.setAssetId(1L); + order.setFaultDesc("资产电源无法开机"); + order.setExpectedFinishTime(new Date()); + order.setRemark("急需使用"); + return order; + } + + private AmsRepairOperateContext adminContext() + { + return operateContext(9L, "管理员", "admin"); + } + + private AmsRepairOperateContext operateContext(Long operateUserId, String operateUserName, String operateLoginName) + { + return new AmsRepairOperateContext(operateUserId, operateUserName, operateLoginName); + } + + private AmsRepairOrder buildPersistedOrder(String status) + { + AmsRepairOrder order = buildRequest(); + order.setRepairId(100L); + order.setRepairNo("BX202606160001"); + order.setOrderStatus(status); + order.setRepairerType("INTERNAL"); + order.setReportUserId(2L); + order.setReportUserName("张三报修人"); + order.setReportTime(new Date()); + order.setCreateBy("admin"); + order.setCreateTime(new Date()); + order.setDelFlag("0"); + return order; + } + + private AmsAsset stubAsset(String status) + { + AmsAsset asset = buildAsset(status); + when(amsAssetMapper.selectAmsAssetByAssetIdForUpdate(1L)).thenReturn(asset); + return asset; + } + + private AmsAsset buildAsset(String status) + { + AmsAsset asset = new AmsAsset(); + asset.setAssetId(1L); + asset.setAssetCode("A001"); + asset.setAssetName("一号资产"); + asset.setCategoryId(10L); + asset.setCategoryCode("CAT-001"); + asset.setCategoryName("类别一"); + asset.setBrand("品牌A"); + asset.setSpecModel("型号X"); + asset.setAssetStatus(status); + asset.setDelFlag("0"); + return asset; + } +}