feat(asset): 优化资产入库流程和表单设计

- 移除资产新增表单中的仓库位置和使用人部门字段,由业务流转服务维护
- 更新资产状态默认值为待入库状态,建档时不设置仓位和使用归属
- 重构入库单明细选择逻辑,改为弹窗选择待入库资产而非直接添加行
- 实现资产归属字段保护机制,防止通过普通编辑绕过业务服务修改
- 优化Excel导入导出配置,区分可编辑字段和业务流转维护字段
- 添加资产状态和仓位的并发占用检测,确保数据一致性
main
yangk 2 weeks ago
parent d67da452a2
commit 9d1cb8f029

@ -7,6 +7,8 @@ package com.ruoyi.asset.constant;
*/
public final class AssetStatus
{
/** 待入库 */
public static final String PENDING_INBOUND = "PENDING_INBOUND";
/** 在库 */
public static final String IN_STOCK = "IN_STOCK";
/** 在用 */

@ -150,7 +150,7 @@ public class AmsAssetController extends BaseController
@GetMapping("/add")
public String add(ModelMap mmap)
{
putAssetOptions(mmap);
putAssetFormOptions(mmap);
return prefix + "/add";
}
@ -180,7 +180,7 @@ public class AmsAssetController extends BaseController
{
AmsAsset amsAsset = amsAssetService.selectAmsAssetByAssetId(assetId);
mmap.put("amsAsset", amsAsset);
putAssetOptions(mmap);
putAssetFormOptions(mmap);
return prefix + "/edit";
}
@ -222,6 +222,13 @@ public class AmsAssetController extends BaseController
mmap.put("userList", selectNormalUserList());
}
/** 新增/编辑表单只需类别和保管人选项,仓库位置和使用归属由业务流转服务维护 */
private void putAssetFormOptions(ModelMap mmap)
{
mmap.put("categoryList", selectEnabledCategoryList());
mmap.put("userList", selectNormalUserList());
}
private List<AmsAssetCategory> selectEnabledCategoryList()
{
AmsAssetCategory category = new AmsAssetCategory();

@ -1,6 +1,7 @@
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;
@ -9,6 +10,7 @@ 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;
@ -16,9 +18,7 @@ import com.ruoyi.asset.domain.AmsInboundOrder;
import com.ruoyi.asset.domain.AmsAsset;
import com.ruoyi.asset.domain.AmsAssetLocation;
import com.ruoyi.asset.domain.AmsWarehouse;
import com.ruoyi.asset.constant.AssetStatus;
import com.ruoyi.asset.service.IAmsAssetLocationService;
import com.ruoyi.asset.service.IAmsAssetService;
import com.ruoyi.asset.service.IAmsInboundOrderService;
import com.ruoyi.asset.service.IAmsWarehouseService;
import com.ruoyi.common.core.controller.BaseController;
@ -49,9 +49,6 @@ public class AmsInboundOrderController extends BaseController
@Autowired
private IAmsAssetLocationService amsAssetLocationService;
@Autowired
private IAmsAssetService amsAssetService;
@RequiresPermissions("asset:inbound:view")
@GetMapping()
public String inbound(ModelMap mmap)
@ -73,6 +70,33 @@ public class AmsInboundOrderController extends BaseController
return getDataTable(list);
}
/**
* /
* <p> orderId </p>
*/
@RequiresPermissions(value = { "asset:inbound:add", "asset:inbound:edit" }, logical = Logical.OR)
@GetMapping("/selectAsset")
public String selectAsset(@RequestParam(value = "orderId", required = false) Long orderId, ModelMap mmap)
{
mmap.put("orderId", orderId);
return prefix + "/selectAsset";
}
/**
* 稿
* <p> PENDING_INBOUND 稿
* orderId </p>
*/
@RequiresPermissions(value = { "asset:inbound:add", "asset:inbound:edit" }, logical = Logical.OR)
@PostMapping("/availableAssetList")
@ResponseBody
public TableDataInfo availableAssetList(AmsAsset amsAsset,
@RequestParam(value = "orderId", required = false) Long orderId)
{
startPage();
return getDataTable(amsInboundOrderService.selectAvailableInboundAssetList(amsAsset, orderId));
}
/**
*
*/
@ -181,10 +205,6 @@ public class AmsInboundOrderController extends BaseController
AmsAssetLocation location = new AmsAssetLocation();
location.setEnabled(ENABLED_YES);
mmap.put("locationList", amsAssetLocationService.selectAmsAssetLocationList(location));
AmsAsset asset = new AmsAsset();
asset.setAssetStatus(AssetStatus.IN_STOCK);
mmap.put("assetList", amsAssetService.selectAmsAssetList(asset));
}
private List<AmsWarehouse> selectEnabledWarehouseList()

@ -49,40 +49,36 @@ public class AmsAsset extends BaseEntity
@Excel(name = "品牌")
private String brand;
/** 当前所属仓库ID */
@Excel(name = "仓库ID", type = Excel.Type.IMPORT)
/** 当前所属仓库ID由入库/调拨业务维护不参与Excel导入导出 */
private Long warehouseId;
/** 当前仓库编码快照 */
@Excel(name = "仓库编码")
@Excel(name = "仓库编码", type = Excel.Type.EXPORT)
private String warehouseCode;
/** 当前仓库名称快照 */
@Excel(name = "仓库名称", type = Excel.Type.EXPORT)
private String warehouseName;
/** 当前存放位置ID */
@Excel(name = "位置ID", type = Excel.Type.IMPORT)
/** 当前存放位置ID由入库/调拨业务维护不参与Excel导入导出 */
private Long locationId;
/** 当前位置编码快照 */
@Excel(name = "位置编码")
@Excel(name = "位置编码", type = Excel.Type.EXPORT)
private String locationCode;
/** 当前位置名称快照 */
@Excel(name = "位置名称", type = Excel.Type.EXPORT)
private String locationName;
/** 使用部门ID */
@Excel(name = "使用部门ID", type = Excel.Type.IMPORT)
/** 使用部门ID由领用/归还业务维护不参与Excel导入导出 */
private Long useDeptId;
/** 使用部门名称快照 */
@Excel(name = "使用部门", type = Excel.Type.EXPORT)
private String useDeptName;
/** 使用人ID */
@Excel(name = "使用人ID", type = Excel.Type.IMPORT)
/** 使用人ID由领用/归还业务维护不参与Excel导入导出 */
private Long useUserId;
/** 使用人名称快照 */
@ -124,8 +120,8 @@ public class AmsAsset extends BaseEntity
@Excel(name = "下次维保日期", width = 30, dateFormat = "yyyy-MM-dd")
private Date nextMaintenanceDate;
/** 当前资产状态 */
@Excel(name = "资产状态", dictType = "ams_asset_status", comboReadDict = true)
/** 当前资产状态(仅导出展示,导入时统一设为待入库,状态变更由流转服务驱动) */
@Excel(name = "资产状态", dictType = "ams_asset_status", type = Excel.Type.EXPORT)
private String assetStatus;
/** 当前绑定标签ID */

@ -1,6 +1,8 @@
package com.ruoyi.asset.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.asset.domain.AmsAsset;
import com.ruoyi.asset.domain.AmsInboundOrder;
import com.ruoyi.asset.domain.AmsInboundOrderItem;
@ -36,6 +38,23 @@ public interface AmsInboundOrderMapper
*/
public List<AmsInboundOrder> selectAmsInboundOrderList(AmsInboundOrder amsInboundOrder);
/**
* 稿使
* <p> PENDING_INBOUND 稿
* currentOrderId </p>
*/
public List<AmsAsset> selectAvailableInboundAssetList(@Param("asset") AmsAsset amsAsset,
@Param("currentOrderId") Long currentOrderId, @Param("pendingStatus") String pendingStatus,
@Param("draftStatus") String draftStatus);
/**
* 稿稿
* <p> selectAmsAssetByAssetIdForUpdate 使
* 稿</p>
*/
public int countOtherDraftInboundOrderByAssetId(@Param("assetId") Long assetId,
@Param("currentOrderId") Long currentOrderId, @Param("draftStatus") String draftStatus);
/**
*
*

@ -1,6 +1,7 @@
package com.ruoyi.asset.service;
import java.util.List;
import com.ruoyi.asset.domain.AmsAsset;
import com.ruoyi.asset.domain.AmsInboundOrder;
/**
@ -27,6 +28,17 @@ public interface IAmsInboundOrderService
*/
public List<AmsInboundOrder> selectAmsInboundOrderList(AmsInboundOrder amsInboundOrder);
/**
* 稿
* <p> PENDING_INBOUND 稿
* /使</p>
*
* @param amsAsset
* @param currentOrderId ID
* @return
*/
public List<AmsAsset> selectAvailableInboundAssetList(AmsAsset amsAsset, Long currentOrderId);
/**
*
*

@ -11,8 +11,8 @@ import com.ruoyi.asset.domain.AssetTransitionResult;
public interface IAssetStatusTransitionService
{
/**
* IN_STOCK IN_STOCK使
* <p></p>
* PENDING_INBOUND IN_STOCK使
* <p>使</p>
*
* @param assetId ID
* @param warehouseId ID

@ -3,16 +3,10 @@ package com.ruoyi.asset.service.impl;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import com.ruoyi.asset.constant.AssetStatus;
import com.ruoyi.asset.domain.AmsAssetCategory;
import com.ruoyi.asset.domain.AmsAssetLocation;
import com.ruoyi.asset.domain.AmsWarehouse;
import com.ruoyi.asset.service.IAmsAssetCategoryService;
import com.ruoyi.asset.service.IAmsAssetLocationService;
import com.ruoyi.asset.service.IAmsWarehouseService;
import com.ruoyi.common.constant.UserConstants;
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.common.core.domain.entity.SysDictData;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException;
@ -24,7 +18,6 @@ import com.ruoyi.asset.mapper.AmsAssetMapper;
import com.ruoyi.asset.domain.AmsAsset;
import com.ruoyi.asset.service.IAmsAssetService;
import com.ruoyi.common.core.text.Convert;
import com.ruoyi.system.service.ISysDeptService;
import com.ruoyi.system.service.ISysDictDataService;
import com.ruoyi.system.service.ISysUserService;
import org.springframework.transaction.annotation.Transactional;
@ -50,15 +43,6 @@ public class AmsAssetServiceImpl implements IAmsAssetService
@Autowired
private IAmsAssetCategoryService amsAssetCategoryService;
@Autowired
private IAmsWarehouseService amsWarehouseService;
@Autowired
private IAmsAssetLocationService amsAssetLocationService;
@Autowired
private ISysDeptService sysDeptService;
@Autowired
private ISysUserService sysUserService;
@ -181,11 +165,12 @@ public class AmsAssetServiceImpl implements IAmsAssetService
{
throw new ServiceException("资产编码已存在");
}
// 建档只产生在库资产,使用归属必须由后续领用业务写入。
amsAsset.setAssetStatus(AssetStatus.IN_STOCK);
// 普通建档只表示实物已登记,仓位和使用归属必须由首次入库及后续流转业务写入。
amsAsset.setAssetStatus(AssetStatus.PENDING_INBOUND);
clearWarehouseLocation(amsAsset);
clearUseOwnership(amsAsset);
clearRfidBinding(amsAsset);
fillAssetForSave(amsAsset);
fillBasicAssetForSave(amsAsset);
amsAsset.setDelFlag(DEL_FLAG_NORMAL);
amsAsset.setCreateTime(DateUtils.getNowDate());
return amsAssetMapper.insertAmsAsset(amsAsset);
@ -209,29 +194,10 @@ public class AmsAssetServiceImpl implements IAmsAssetService
{
throw new ServiceException("资产编码已存在");
}
// 检查并拒绝有业务引用的资产修改归属字段,归属只能通过流转服务变更
int referenceCount = amsAssetMapper.countAssetReferences(current.getAssetId());
if (referenceCount > 0 && ownershipIdentityChanged(current, amsAsset))
{
throw new ServiceException("资产已有业务引用或履历,仓库、位置、使用人和部门只能通过业务流转修改");
}
// 普通编辑不能改变状态或 RFID 当前关系,避免绕过公共业务服务。
// 普通编辑不能改变状态、当前归属或 RFID 当前关系,避免绕过公共业务服务。
preserveControlledFields(amsAsset, current);
if (referenceCount > 0)
{
// 有业务引用的资产:保留当前归属快照,仅允许修改基础信息和类别
fillReferencedAssetForSave(amsAsset, current);
}
else
{
// 未进入业务流程的资产:在库状态下清除使用归属,然后执行完整快照回填
if (StringUtils.equals(AssetStatus.IN_STOCK, current.getAssetStatus()))
{
clearUseOwnership(amsAsset);
}
fillAssetForSave(amsAsset);
}
preserveOwnershipFields(amsAsset, current);
fillBasicAssetForSave(amsAsset);
amsAsset.setUpdateTime(DateUtils.getNowDate());
return amsAssetMapper.updateAmsAsset(amsAsset);
}
@ -286,27 +252,10 @@ public class AmsAssetServiceImpl implements IAmsAssetService
/**
*
*/
private void fillAssetForSave(AmsAsset amsAsset)
private void fillBasicAssetForSave(AmsAsset amsAsset)
{
validateBasicFields(amsAsset);
fillCategorySnapshot(amsAsset);
fillWarehouseSnapshot(amsAsset);
fillLocationSnapshot(amsAsset);
fillUseDeptSnapshot(amsAsset);
fillUserSnapshot(amsAsset);
fillCustodianSnapshot(amsAsset);
validateAssetStatus(amsAsset);
validateNumberFields(amsAsset);
}
/**
*
*/
private void fillReferencedAssetForSave(AmsAsset amsAsset, AmsAsset current)
{
validateBasicFields(amsAsset);
fillCategorySnapshot(amsAsset);
preserveOwnershipFields(amsAsset, current);
fillCustodianSnapshot(amsAsset);
validateAssetStatus(amsAsset);
validateNumberFields(amsAsset);
@ -360,96 +309,6 @@ public class AmsAssetServiceImpl implements IAmsAssetService
}
}
private void fillWarehouseSnapshot(AmsAsset amsAsset)
{
AmsWarehouse warehouse = null;
if (StringUtils.isNotNull(amsAsset.getWarehouseId()))
{
warehouse = amsWarehouseService.selectAmsWarehouseByWarehouseId(amsAsset.getWarehouseId());
}
else if (StringUtils.isNotEmpty(amsAsset.getWarehouseCode()))
{
AmsWarehouse query = new AmsWarehouse();
query.setWarehouseCode(amsAsset.getWarehouseCode());
List<AmsWarehouse> warehouseList = amsWarehouseService.selectAmsWarehouseList(query);
warehouse = warehouseList == null || warehouseList.isEmpty() ? null : warehouseList.get(0);
}
if (StringUtils.isNull(warehouse))
{
throw new ServiceException("所属仓库不能为空或不存在");
}
if (!StringUtils.equals(ENABLED_YES, warehouse.getEnabled()))
{
throw new ServiceException("所属仓库已停用");
}
amsAsset.setWarehouseId(warehouse.getWarehouseId());
amsAsset.setWarehouseCode(warehouse.getWarehouseCode());
amsAsset.setWarehouseName(warehouse.getWarehouseName());
}
private void fillLocationSnapshot(AmsAsset amsAsset)
{
AmsAssetLocation location = null;
if (StringUtils.isNotNull(amsAsset.getLocationId()))
{
location = amsAssetLocationService.selectAmsAssetLocationByLocationId(amsAsset.getLocationId());
}
else if (StringUtils.isNotEmpty(amsAsset.getLocationCode()))
{
AmsAssetLocation query = new AmsAssetLocation();
query.setLocationCode(amsAsset.getLocationCode());
List<AmsAssetLocation> locationList = amsAssetLocationService.selectAmsAssetLocationList(query);
location = locationList == null || locationList.isEmpty() ? null : locationList.get(0);
}
if (StringUtils.isNull(location))
{
throw new ServiceException("存放位置不能为空或不存在");
}
if (!StringUtils.equals(ENABLED_YES, location.getEnabled()))
{
throw new ServiceException("存放位置已停用");
}
if (StringUtils.isNotNull(amsAsset.getWarehouseId())
&& !amsAsset.getWarehouseId().equals(location.getWarehouseId()))
{
throw new ServiceException("存放位置不属于所选仓库");
}
amsAsset.setLocationId(location.getLocationId());
amsAsset.setLocationCode(location.getLocationCode());
amsAsset.setLocationName(location.getLocationName());
}
private void fillUseDeptSnapshot(AmsAsset amsAsset)
{
if (StringUtils.isNull(amsAsset.getUseDeptId()))
{
amsAsset.setUseDeptName(null);
return;
}
SysDept query = new SysDept();
query.setDeptId(amsAsset.getUseDeptId());
query.setStatus(UserConstants.DEPT_NORMAL);
List<SysDept> deptList = sysDeptService.selectDeptList(query);
if (deptList == null || deptList.isEmpty())
{
throw new ServiceException("使用部门不存在或已停用");
}
amsAsset.setUseDeptName(deptList.get(0).getDeptName());
}
private void fillUserSnapshot(AmsAsset amsAsset)
{
if (StringUtils.isNull(amsAsset.getUseUserId()))
{
amsAsset.setUseUserName(null);
return;
}
SysUser user = selectNormalUser(amsAsset.getUseUserId(), "使用人");
amsAsset.setUseUserName(user.getUserName());
}
private void fillCustodianSnapshot(AmsAsset amsAsset)
{
if (StringUtils.isNull(amsAsset.getCustodianId()))
@ -507,15 +366,6 @@ public class AmsAssetServiceImpl implements IAmsAssetService
}
}
/** 判断编辑提交的归属字段是否与当前数据库值不同(任一不同即视为变更) */
private boolean ownershipIdentityChanged(AmsAsset current, AmsAsset incoming)
{
return !Objects.equals(current.getWarehouseId(), incoming.getWarehouseId())
|| !Objects.equals(current.getLocationId(), incoming.getLocationId())
|| !Objects.equals(current.getUseDeptId(), incoming.getUseDeptId())
|| !Objects.equals(current.getUseUserId(), incoming.getUseUserId());
}
/** 防篡改:将状态和 RFID 关系强制恢复为数据库当前值,阻止前端直接修改这些受控字段 */
private void preserveControlledFields(AmsAsset incoming, AmsAsset current)
{
@ -525,7 +375,7 @@ public class AmsAssetServiceImpl implements IAmsAssetService
incoming.setEpcCode(current.getEpcCode());
}
/** 有业务引用时保留当前归属快照不变,避免历史主数据停用后阻断基础信息的日常维护 */
/** 普通编辑始终保留当前归属快照,归属只能通过业务流转服务修改。 */
private void preserveOwnershipFields(AmsAsset incoming, AmsAsset current)
{
incoming.setWarehouseId(current.getWarehouseId());
@ -540,7 +390,18 @@ public class AmsAssetServiceImpl implements IAmsAssetService
incoming.setUseUserName(current.getUseUserName());
}
/** 清除使用归属信息(建档和在库编辑时调用,在库资产不应有使用人信息) */
/** 清除仓库和位置,待入库资产在首次确认入库前不能拥有仓位。 */
private void clearWarehouseLocation(AmsAsset asset)
{
asset.setWarehouseId(null);
asset.setWarehouseCode(null);
asset.setWarehouseName(null);
asset.setLocationId(null);
asset.setLocationCode(null);
asset.setLocationName(null);
}
/** 清除使用归属信息,待入库和在库资产均不应有使用归属。 */
private void clearUseOwnership(AmsAsset asset)
{
asset.setUseDeptId(null);

@ -2,6 +2,7 @@ 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;
@ -14,9 +15,9 @@ import com.ruoyi.asset.domain.AmsInboundOrder;
import com.ruoyi.asset.domain.AmsInboundOrderItem;
import com.ruoyi.asset.domain.AmsWarehouse;
import com.ruoyi.asset.domain.AssetTransitionContext;
import com.ruoyi.asset.mapper.AmsAssetMapper;
import com.ruoyi.asset.mapper.AmsInboundOrderMapper;
import com.ruoyi.asset.service.IAmsAssetLocationService;
import com.ruoyi.asset.service.IAmsAssetService;
import com.ruoyi.asset.service.IAmsInboundOrderService;
import com.ruoyi.asset.service.IAmsWarehouseService;
import com.ruoyi.asset.service.IAssetStatusTransitionService;
@ -46,6 +47,9 @@ public class AmsInboundOrderServiceImpl implements IAmsInboundOrderService
@Autowired
private AmsInboundOrderMapper amsInboundOrderMapper;
@Autowired
private AmsAssetMapper amsAssetMapper;
@Autowired
private ISysCodeRuleService sysCodeRuleService;
@ -55,9 +59,6 @@ public class AmsInboundOrderServiceImpl implements IAmsInboundOrderService
@Autowired
private IAmsAssetLocationService amsAssetLocationService;
@Autowired
private IAmsAssetService amsAssetService;
@Autowired
private IAssetStatusTransitionService assetStatusTransitionService;
@ -73,6 +74,16 @@ public class AmsInboundOrderServiceImpl implements IAmsInboundOrderService
return amsInboundOrderMapper.selectAmsInboundOrderList(amsInboundOrder);
}
/**
*
*/
@Override
public List<AmsAsset> selectAvailableInboundAssetList(AmsAsset amsAsset, Long currentOrderId)
{
return amsInboundOrderMapper.selectAvailableInboundAssetList(amsAsset, currentOrderId,
AssetStatus.PENDING_INBOUND, InboundOrderStatus.DRAFT);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int insertAmsInboundOrder(AmsInboundOrder amsInboundOrder)
@ -85,7 +96,7 @@ public class AmsInboundOrderServiceImpl implements IAmsInboundOrderService
amsInboundOrder.setInboundTime(null);
amsInboundOrder.setDelFlag(DEL_FLAG_NORMAL);
amsInboundOrder.setCreateTime(DateUtils.getNowDate());
fillOrderSnapshots(amsInboundOrder);
fillOrderSnapshots(amsInboundOrder, null);
int rows = amsInboundOrderMapper.insertAmsInboundOrder(amsInboundOrder);
if (rows != 1 || StringUtils.isNull(amsInboundOrder.getOrderId()))
@ -114,7 +125,7 @@ public class AmsInboundOrderServiceImpl implements IAmsInboundOrderService
amsInboundOrder.setInboundUserName(current.getInboundUserName());
amsInboundOrder.setInboundTime(current.getInboundTime());
amsInboundOrder.setUpdateTime(DateUtils.getNowDate());
fillOrderSnapshots(amsInboundOrder);
fillOrderSnapshots(amsInboundOrder, amsInboundOrder.getOrderId());
amsInboundOrderMapper.deleteAmsInboundOrderItemByOrderId(amsInboundOrder.getOrderId());
insertAmsInboundOrderItem(amsInboundOrder);
@ -135,7 +146,10 @@ public class AmsInboundOrderServiceImpl implements IAmsInboundOrderService
{
throw new ServiceException("入库单明细不能为空");
}
for (AmsInboundOrderItem item : itemList)
// 所有批量流转统一按资产ID升序执行保证与草稿保存使用相同锁序。
List<AmsInboundOrderItem> sortedItems = new ArrayList<>(itemList);
sortedItems.sort(Comparator.comparing(AmsInboundOrderItem::getAssetId));
for (AmsInboundOrderItem item : sortedItems)
{
AssetTransitionContext context = new AssetTransitionContext();
context.setSourceOrderId(order.getOrderId());
@ -240,8 +254,13 @@ public class AmsInboundOrderServiceImpl implements IAmsInboundOrderService
/**
* ID
* <p> assetId FOR UPDATE
* confirmInbound 使</p>
*
* @param order
* @param currentOrderId ID null稿
*/
private void fillOrderSnapshots(AmsInboundOrder order)
private void fillOrderSnapshots(AmsInboundOrder order, Long currentOrderId)
{
AmsWarehouse warehouse = amsWarehouseService.selectAmsWarehouseByWarehouseId(order.getWarehouseId());
if (StringUtils.isNull(warehouse) || !StringUtils.equals(ENABLED_YES, warehouse.getEnabled()))
@ -252,7 +271,11 @@ public class AmsInboundOrderServiceImpl implements IAmsInboundOrderService
order.setWarehouseName(warehouse.getWarehouseName());
Set<Long> assetIds = new HashSet<>();
for (AmsInboundOrderItem item : order.getAmsInboundOrderItemList())
List<AmsInboundOrderItem> sortedItems = new ArrayList<>(order.getAmsInboundOrderItemList());
sortedItems.sort(Comparator.comparing(AmsInboundOrderItem::getAssetId,
Comparator.nullsFirst(Long::compareTo)));
order.setAmsInboundOrderItemList(sortedItems);
for (AmsInboundOrderItem item : sortedItems)
{
if (StringUtils.isNull(item.getAssetId()))
{
@ -262,15 +285,21 @@ public class AmsInboundOrderServiceImpl implements IAmsInboundOrderService
{
throw new ServiceException("同一入库单不能重复选择资产");
}
AmsAsset asset = amsAssetService.selectAmsAssetByAssetId(item.getAssetId());
// 保存草稿时锁定资产,避免两个并发草稿同时通过占用校验。
AmsAsset asset = amsAssetMapper.selectAmsAssetByAssetIdForUpdate(item.getAssetId());
if (StringUtils.isNull(asset))
{
throw new ServiceException("入库资产不存在或已删除");
}
if (!StringUtils.equals(AssetStatus.IN_STOCK, asset.getAssetStatus()))
if (!StringUtils.equals(AssetStatus.PENDING_INBOUND, asset.getAssetStatus()))
{
throw new ServiceException(StringUtils.format("资产【{}】当前状态不允许入库", asset.getAssetCode()));
}
if (amsInboundOrderMapper.countOtherDraftInboundOrderByAssetId(asset.getAssetId(), currentOrderId,
InboundOrderStatus.DRAFT) > 0)
{
throw new ServiceException(StringUtils.format("资产【{}】已被其他草稿入库单占用", asset.getAssetCode()));
}
if (StringUtils.isNull(item.getLocationId()))
{
throw new ServiceException(StringUtils.format("资产【{}】的入库位置不能为空", asset.getAssetCode()));

@ -73,8 +73,8 @@ public class AssetStatusTransitionServiceImpl implements IAssetStatusTransitionS
public AssetTransitionResult confirmInbound(Long assetId, Long warehouseId, Long locationId,
AssetTransitionContext context)
{
// 入库允许 IN_STOCK→IN_STOCK调仓换位场景更新仓位并清除使用归属
return executeTransition(assetId, Set.of(AssetStatus.IN_STOCK), asset -> AssetStatus.IN_STOCK,
// 首次入库是待入库资产进入仓库的唯一入口,在库资产换仓必须走调拨业务。
return executeTransition(assetId, Set.of(AssetStatus.PENDING_INBOUND), asset -> AssetStatus.IN_STOCK,
context, AssetLifecycleBusinessType.INBOUND, "确认资产入库",
asset -> {
fillWarehouseLocation(asset, warehouseId, locationId);

@ -51,6 +51,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="delFlag" column="del_flag" />
</resultMap>
<!-- 入库单明细弹窗选择器使用的精简结果映射,只返回资产基本信息 -->
<resultMap type="AmsAsset" id="AvailableInboundAssetResult">
<result property="assetId" column="asset_id" />
<result property="assetCode" column="asset_code" />
<result property="assetName" column="asset_name" />
<result property="categoryId" column="category_id" />
<result property="categoryCode" column="category_code" />
<result property="categoryName" column="category_name" />
<result property="specModel" column="spec_model" />
<result property="brand" column="brand" />
<result property="assetStatus" column="asset_status" />
<result property="tagCode" column="tag_code" />
</resultMap>
<sql id="selectAmsInboundOrderVo">
select order_id, inbound_no, warehouse_id, warehouse_code, warehouse_name,
inbound_user_id, inbound_user_name, inbound_time, order_status,
@ -98,6 +112,52 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
for update
</select>
<!-- 查询可入库资产候选列表:只筛选待入库状态,并通过 NOT EXISTS 排除已被其他有效草稿占用的资产 -->
<select id="selectAvailableInboundAssetList" resultMap="AvailableInboundAssetResult">
select asset.asset_id, asset.asset_code, asset.asset_name, asset.category_id,
asset.category_code, asset.category_name, asset.spec_model, asset.brand,
asset.asset_status, asset.tag_code
from ams_asset asset
where asset.del_flag = '0'
and asset.asset_status = #{pendingStatus}
and not exists (
select 1
from ams_inbound_order_item item
inner join ams_inbound_order inbound on inbound.order_id = item.order_id
and inbound.del_flag = '0'
and inbound.order_status = #{draftStatus}
where item.asset_id = asset.asset_id
and item.del_flag = '0'
<if test="currentOrderId != null">
and inbound.order_id != #{currentOrderId}
</if>
)
<if test="asset.assetCode != null and asset.assetCode != ''">
and asset.asset_code like concat('%', #{asset.assetCode}, '%')
</if>
<if test="asset.assetName != null and asset.assetName != ''">
and asset.asset_name like concat('%', #{asset.assetName}, '%')
</if>
<if test="asset.categoryName != null and asset.categoryName != ''">
and asset.category_name like concat('%', #{asset.categoryName}, '%')
</if>
order by asset.asset_id
</select>
<!-- 保存草稿时防重复占用校验:统计同一资产在其他有效草稿入库单中的引用次数 -->
<select id="countOtherDraftInboundOrderByAssetId" resultType="int">
select count(1)
from ams_inbound_order_item item
inner join ams_inbound_order inbound on inbound.order_id = item.order_id
and inbound.del_flag = '0'
and inbound.order_status = #{draftStatus}
where item.asset_id = #{assetId}
and item.del_flag = '0'
<if test="currentOrderId != null">
and inbound.order_id != #{currentOrderId}
</if>
</select>
<select id="selectAmsInboundOrderItemList" resultMap="AmsInboundOrderItemResult">
select item_id, order_id, inbound_no, asset_id, asset_code, asset_name, category_id,
category_code, category_name, spec_model, brand, location_id, location_code,

@ -51,50 +51,6 @@
</div>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label class="col-sm-4 control-label is-required">所属仓库:</label>
<div class="col-sm-8">
<select id="warehouseId" name="warehouseId" class="form-control select2-control" required>
<option value="">请选择</option>
<option th:each="warehouse : ${warehouseList}" th:text="${warehouse.warehouseName}" th:value="${warehouse.warehouseId}"></option>
</select>
</div>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label class="col-sm-4 control-label is-required">存放位置:</label>
<div class="col-sm-8">
<select id="locationId" name="locationId" class="form-control select2-control" required>
<option value="">请选择</option>
<option th:each="location : ${locationList}" th:text="${location.locationName}" th:value="${location.locationId}" th:data-warehouse-id="${location.warehouseId}"></option>
</select>
</div>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label class="col-sm-4 control-label">使用部门:</label>
<div class="col-sm-8">
<select name="useDeptId" class="form-control select2-control">
<option value="">请选择</option>
<option th:each="dept : ${deptList}" th:text="${dept.deptName}" th:value="${dept.deptId}"></option>
</select>
</div>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label class="col-sm-4 control-label">使用人:</label>
<div class="col-sm-8">
<select name="useUserId" class="form-control select2-control">
<option value="">请选择</option>
<option th:each="user : ${userList}" th:text="${user.userName + '' + user.loginName + ''}" th:value="${user.userId}"></option>
</select>
</div>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label class="col-sm-4 control-label">保管人:</label>
@ -178,24 +134,13 @@
<th:block th:include="include :: select2-js" />
<script th:inline="javascript">
var prefix = ctx + "asset/asset"
var allLocationOptions = [];
var syncingWarehouseAndLocation = false;
initLocationOptions();
$(".select2-control").select2({
placeholder: "请选择",
allowClear: true
});
$("#warehouseId").on("change", function() {
if (!syncingWarehouseAndLocation) {
refreshLocationOptions();
}
});
$("#locationId").on("change", fillWarehouseByLocation);
$("#categoryId").on("change", fillCategoryDefaults);
$("input[name='maintenanceCycle']").on("input change", calculateNextMaintenanceDate);
$("input[name='lastMaintenanceDate']").on("change changeDate", calculateNextMaintenanceDate);
refreshLocationOptions();
$("#form-asset-add").validate({
focusCleanup: true
@ -207,26 +152,6 @@
}
}
function refreshLocationOptions() {
var warehouseId = $("#warehouseId").val();
var $location = $("#locationId");
var selectedLocationId = $location.val();
$location.empty().append('<option value="">请选择</option>');
$.each(allLocationOptions, function(index, location) {
if (!warehouseId || String(location.warehouseId) === String(warehouseId)) {
var option = new Option(location.text, location.value, false, false);
$(option).attr("data-warehouse-id", location.warehouseId);
$location.append(option);
}
});
if (selectedLocationId && $location.find("option[value='" + selectedLocationId + "']").length > 0) {
$location.val(selectedLocationId);
} else {
$location.val("");
}
$location.trigger("change.select2");
}
function fillCategoryDefaults() {
var $option = $("#categoryId").find("option:selected");
if (!$option.val()) {
@ -240,44 +165,6 @@
calculateNextMaintenanceDate();
}
function initLocationOptions() {
$("#locationId").find("option[data-warehouse-id]").each(function() {
allLocationOptions.push({
value: $(this).val(),
text: $(this).text(),
warehouseId: $(this).attr("data-warehouse-id")
});
});
}
function fillWarehouseByLocation() {
if (syncingWarehouseAndLocation) {
return;
}
var selectedLocationId = $("#locationId").val();
if (!selectedLocationId) {
return;
}
var location = findLocationOption(selectedLocationId);
if (!location || !location.warehouseId) {
return;
}
syncingWarehouseAndLocation = true;
$("#warehouseId").val(location.warehouseId).trigger("change.select2");
refreshLocationOptions();
$("#locationId").val(selectedLocationId).trigger("change.select2");
syncingWarehouseAndLocation = false;
}
function findLocationOption(locationId) {
for (var i = 0; i < allLocationOptions.length; i++) {
if (String(allLocationOptions[i].value) === String(locationId)) {
return allLocationOptions[i];
}
}
return null;
}
function calculateNextMaintenanceDate() {
var lastDate = parseDateValue($("input[name='lastMaintenanceDate']").val());
var cycle = parseInt($("input[name='maintenanceCycle']").val(), 10);

@ -239,7 +239,7 @@
<div class="col-xs-offset-1">
<input type="file" id="file" name="file"/>
<div class="mt10 pt5">
资产编码已存在时导入失败,不覆盖已有资产。
导入资产统一创建为待入库状态,仓库、位置和使用归属由首次入库及后续业务维护;资产编码已存在时导入失败,不覆盖已有资产。
&nbsp; <a onclick="$.table.importTemplate()" class="btn btn-default btn-xs"><i class="fa fa-file-excel-o"></i> 下载模板</a>
</div>
<font color="red" class="pull-left mt10">

@ -52,50 +52,6 @@
</div>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label class="col-sm-4 control-label is-required">所属仓库:</label>
<div class="col-sm-8">
<select id="warehouseId" name="warehouseId" th:field="*{warehouseId}" class="form-control select2-control" required>
<option value="">请选择</option>
<option th:each="warehouse : ${warehouseList}" th:text="${warehouse.warehouseName}" th:value="${warehouse.warehouseId}"></option>
</select>
</div>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label class="col-sm-4 control-label is-required">存放位置:</label>
<div class="col-sm-8">
<select id="locationId" name="locationId" th:field="*{locationId}" class="form-control select2-control" required>
<option value="">请选择</option>
<option th:each="location : ${locationList}" th:text="${location.locationName}" th:value="${location.locationId}" th:data-warehouse-id="${location.warehouseId}"></option>
</select>
</div>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label class="col-sm-4 control-label">使用部门:</label>
<div class="col-sm-8">
<select name="useDeptId" th:field="*{useDeptId}" class="form-control select2-control">
<option value="">请选择</option>
<option th:each="dept : ${deptList}" th:text="${dept.deptName}" th:value="${dept.deptId}"></option>
</select>
</div>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label class="col-sm-4 control-label">使用人:</label>
<div class="col-sm-8">
<select name="useUserId" th:field="*{useUserId}" class="form-control select2-control">
<option value="">请选择</option>
<option th:each="user : ${userList}" th:text="${user.userName + '' + user.loginName + ''}" th:value="${user.userId}"></option>
</select>
</div>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label class="col-sm-4 control-label">保管人:</label>
@ -179,24 +135,13 @@
<th:block th:include="include :: select2-js" />
<script th:inline="javascript">
var prefix = ctx + "asset/asset";
var allLocationOptions = [];
var syncingWarehouseAndLocation = false;
initLocationOptions();
$(".select2-control").select2({
placeholder: "请选择",
allowClear: true
});
$("#warehouseId").on("change", function() {
if (!syncingWarehouseAndLocation) {
refreshLocationOptions();
}
});
$("#locationId").on("change", fillWarehouseByLocation);
$("#categoryId").on("change", fillCategoryDefaults);
$("input[name='maintenanceCycle']").on("input change", calculateNextMaintenanceDate);
$("input[name='lastMaintenanceDate']").on("change changeDate", calculateNextMaintenanceDate);
refreshLocationOptions();
$("#form-asset-edit").validate({
focusCleanup: true
@ -208,26 +153,6 @@
}
}
function refreshLocationOptions() {
var warehouseId = $("#warehouseId").val();
var $location = $("#locationId");
var selectedLocationId = $location.val();
$location.empty().append('<option value="">请选择</option>');
$.each(allLocationOptions, function(index, location) {
if (!warehouseId || String(location.warehouseId) === String(warehouseId)) {
var option = new Option(location.text, location.value, false, false);
$(option).attr("data-warehouse-id", location.warehouseId);
$location.append(option);
}
});
if (selectedLocationId && $location.find("option[value='" + selectedLocationId + "']").length > 0) {
$location.val(selectedLocationId);
} else {
$location.val("");
}
$location.trigger("change.select2");
}
function fillCategoryDefaults() {
var $option = $("#categoryId").find("option:selected");
if (!$option.val()) {
@ -241,44 +166,6 @@
calculateNextMaintenanceDate();
}
function initLocationOptions() {
$("#locationId").find("option[data-warehouse-id]").each(function() {
allLocationOptions.push({
value: $(this).val(),
text: $(this).text(),
warehouseId: $(this).attr("data-warehouse-id")
});
});
}
function fillWarehouseByLocation() {
if (syncingWarehouseAndLocation) {
return;
}
var selectedLocationId = $("#locationId").val();
if (!selectedLocationId) {
return;
}
var location = findLocationOption(selectedLocationId);
if (!location || !location.warehouseId) {
return;
}
syncingWarehouseAndLocation = true;
$("#warehouseId").val(location.warehouseId).trigger("change.select2");
refreshLocationOptions();
$("#locationId").val(selectedLocationId).trigger("change.select2");
syncingWarehouseAndLocation = false;
}
function findLocationOption(locationId) {
for (var i = 0; i < allLocationOptions.length; i++) {
if (String(allLocationOptions[i].value) === String(locationId)) {
return allLocationOptions[i];
}
}
return null;
}
function calculateNextMaintenanceDate() {
var lastDate = parseDateValue($("input[name='lastMaintenanceDate']").val());
var cycle = parseInt($("input[name='maintenanceCycle']").val(), 10);

@ -38,8 +38,8 @@
<h4 class="form-header h4">入库明细</h4>
<div class="row">
<div class="col-sm-12">
<button type="button" class="btn btn-white btn-sm" onclick="addRow()">
<i class="fa fa-plus"> 增加</i>
<button type="button" class="btn btn-white btn-sm" onclick="selectAssets()">
<i class="fa fa-plus"> 选择资产</i>
</button>
<button type="button" class="btn btn-white btn-sm" onclick="sub.delRow()">
<i class="fa fa-minus"> 删除</i>
@ -54,7 +54,6 @@
<th:block th:include="include :: footer" />
<script th:inline="javascript">
var prefix = ctx + "asset/inbound";
var assetList = [[${assetList}]];
var locationList = [[${locationList}]];
$("#form-inbound-add").validate({
@ -82,6 +81,9 @@
});
function initDetailTable(data) {
$.each(data, function(index, row) {
row.index = $.table.serialNumber(index);
});
var options = {
data: data,
pagination: false,
@ -105,9 +107,13 @@
field: 'assetId',
title: '资产',
formatter: function(value, row, index) {
return buildAssetSelect(value, index);
return buildAssetCell(row, index);
}
},
{
field: 'categoryName',
title: '资产类别'
},
{
field: 'locationId',
title: '入库位置',
@ -144,39 +150,66 @@
$.table.init(options);
}
function addRow() {
/** 打开待入库资产弹窗选择器新增场景不传orderId */
function selectAssets() {
if ($.common.isEmpty($("#warehouseId").val())) {
$.modal.alertWarning("请先选择入库仓库");
return;
}
var count = $("#bootstrap-table").bootstrapTable('getData').length;
sub.addRow({
index: $.table.serialNumber(count),
assetId: "",
locationId: "",
inboundQuantity: 1,
remark: ""
$.modal.openOptions({
title: "选择待入库资产",
url: prefix + "/selectAsset",
width: "1100",
height: "650",
callBack: addSelectedAssets
});
}
function buildAssetSelect(value, index) {
var select = $("<select>").addClass("form-control").attr({
/** 弹窗回调:将选中的资产行写入明细表,已存在的资产自动跳过去重 */
function addSelectedAssets(index, layero) {
var selectedAssets = layero.find("iframe")[0].contentWindow.getSelectedAssets();
if (!selectedAssets || selectedAssets.length === 0) {
$.modal.alertWarning("请至少选择一条资产记录");
return;
}
var existing = {};
$.each($("#bootstrap-table").bootstrapTable('getData'), function(i, asset) {
existing[String(asset.assetId)] = true;
});
$.each(selectedAssets, function(i, asset) {
if (!existing[String(asset.assetId)]) {
asset.index = nextDetailIndex();
asset.locationId = "";
asset.inboundQuantity = 1;
asset.remark = "";
sub.addRow(asset);
existing[String(asset.assetId)] = true;
}
});
$.modal.close(index);
}
/** 计算下一条明细的序号基于当前最大序号递增以保持表单name连续 */
function nextDetailIndex() {
var nextIndex = 1;
$.each($("#bootstrap-table").bootstrapTable('getData'), function(i, row) {
var currentIndex = parseInt(row.index, 10);
if (!isNaN(currentIndex) && currentIndex >= nextIndex) {
nextIndex = currentIndex + 1;
}
});
return nextIndex;
}
/** 资产列渲染生成隐藏字段提交assetId+ 可读标签(展示编码-名称) */
function buildAssetCell(row, index) {
var hidden = $("<input>").attr({
type: "hidden",
name: "amsInboundOrderItemList[" + index + "].assetId",
required: true
});
select.append($("<option>").val("").text("请选择资产"));
$.each(assetList, function(i, asset) {
var label = asset.assetCode + " - " + asset.assetName;
if ($.common.isNotEmpty(asset.categoryName)) {
label += " - " + asset.categoryName;
}
var option = $("<option>").val(asset.assetId).text(label);
if (String(asset.assetId) === String(value)) {
option.attr("selected", "selected");
}
select.append(option);
});
return select.prop("outerHTML");
value: row.assetId
}).prop("outerHTML");
var label = $("<span>").text(row.assetCode + " - " + row.assetName).prop("outerHTML");
return hidden + label;
}
function buildLocationSelect(value, index) {

@ -50,8 +50,8 @@
<h4 class="form-header h4">入库明细</h4>
<div class="row">
<div class="col-sm-12">
<button type="button" class="btn btn-white btn-sm" onclick="addRow()">
<i class="fa fa-plus"> 增加</i>
<button type="button" class="btn btn-white btn-sm" onclick="selectAssets()">
<i class="fa fa-plus"> 选择资产</i>
</button>
<button type="button" class="btn btn-white btn-sm" onclick="sub.delRow()">
<i class="fa fa-minus"> 删除</i>
@ -66,9 +66,10 @@
<th:block th:include="include :: footer" />
<script th:inline="javascript">
var prefix = ctx + "asset/inbound";
var assetList = [[${assetList}]];
var locationList = [[${locationList}]];
var detailList = [[${amsInboundOrder.amsInboundOrderItemList}]];
// 编辑场景需传入orderId使待入库资产选择器排除当前单据自身占用的资产
var orderId = [[${amsInboundOrder.orderId}]];
$("#form-inbound-edit").validate({
focusCleanup: true
@ -95,6 +96,9 @@
});
function initDetailTable(data) {
$.each(data, function(index, row) {
row.index = $.table.serialNumber(index);
});
var options = {
data: data,
pagination: false,
@ -118,9 +122,13 @@
field: 'assetId',
title: '资产',
formatter: function(value, row, index) {
return buildAssetSelect(value, index);
return buildAssetCell(row, index);
}
},
{
field: 'categoryName',
title: '资产类别'
},
{
field: 'locationId',
title: '入库位置',
@ -157,39 +165,66 @@
$.table.init(options);
}
function addRow() {
/** 打开待入库资产弹窗选择器编辑场景携带orderId排除自身占用 */
function selectAssets() {
if ($.common.isEmpty($("#warehouseId").val())) {
$.modal.alertWarning("请先选择入库仓库");
return;
}
var count = $("#bootstrap-table").bootstrapTable('getData').length;
sub.addRow({
index: $.table.serialNumber(count),
assetId: "",
locationId: "",
inboundQuantity: 1,
remark: ""
$.modal.openOptions({
title: "选择待入库资产",
url: prefix + "/selectAsset?orderId=" + orderId,
width: "1100",
height: "650",
callBack: addSelectedAssets
});
}
function buildAssetSelect(value, index) {
var select = $("<select>").addClass("form-control").attr({
/** 弹窗回调:将选中的资产行写入明细表,已存在的资产自动跳过去重 */
function addSelectedAssets(index, layero) {
var selectedAssets = layero.find("iframe")[0].contentWindow.getSelectedAssets();
if (!selectedAssets || selectedAssets.length === 0) {
$.modal.alertWarning("请至少选择一条资产记录");
return;
}
var existing = {};
$.each($("#bootstrap-table").bootstrapTable('getData'), function(i, asset) {
existing[String(asset.assetId)] = true;
});
$.each(selectedAssets, function(i, asset) {
if (!existing[String(asset.assetId)]) {
asset.index = nextDetailIndex();
asset.locationId = "";
asset.inboundQuantity = 1;
asset.remark = "";
sub.addRow(asset);
existing[String(asset.assetId)] = true;
}
});
$.modal.close(index);
}
/** 计算下一条明细的序号基于当前最大序号递增以保持表单name连续 */
function nextDetailIndex() {
var nextIndex = 1;
$.each($("#bootstrap-table").bootstrapTable('getData'), function(i, row) {
var currentIndex = parseInt(row.index, 10);
if (!isNaN(currentIndex) && currentIndex >= nextIndex) {
nextIndex = currentIndex + 1;
}
});
return nextIndex;
}
/** 资产列渲染生成隐藏字段提交assetId+ 可读标签(展示编码-名称) */
function buildAssetCell(row, index) {
var hidden = $("<input>").attr({
type: "hidden",
name: "amsInboundOrderItemList[" + index + "].assetId",
required: true
});
select.append($("<option>").val("").text("请选择资产"));
$.each(assetList, function(i, asset) {
var label = asset.assetCode + " - " + asset.assetName;
if ($.common.isNotEmpty(asset.categoryName)) {
label += " - " + asset.categoryName;
}
var option = $("<option>").val(asset.assetId).text(label);
if (String(asset.assetId) === String(value)) {
option.attr("selected", "selected");
}
select.append(option);
});
return select.prop("outerHTML");
value: row.assetId
}).prop("outerHTML");
var label = $("<span>").text(row.assetCode + " - " + row.assetName).prop("outerHTML");
return hidden + label;
}
function buildLocationSelect(value, index) {

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<th:block th:include="include :: header('选择待入库资产')" />
</head>
<body class="gray-bg">
<div class="container-div">
<div class="row">
<div class="col-sm-12 search-collapse">
<form id="formId">
<div class="select-list">
<ul>
<li>
<label>资产编码:</label>
<input type="text" name="assetCode">
</li>
<li>
<label>资产名称:</label>
<input type="text" name="assetName">
</li>
<li>
<label>资产类别:</label>
<input type="text" name="categoryName">
</li>
<li>
<a class="btn btn-primary btn-rounded btn-sm" onclick="$.table.search()">
<i class="fa fa-search"></i>&nbsp;搜索
</a>
<a class="btn btn-warning btn-rounded btn-sm" onclick="$.form.reset()">
<i class="fa fa-refresh"></i>&nbsp;重置
</a>
</li>
</ul>
</div>
</form>
</div>
<div class="col-sm-12 select-table table-striped">
<table id="bootstrap-table"></table>
</div>
</div>
</div>
<th:block th:include="include :: footer" />
<script th:inline="javascript">
var prefix = ctx + "asset/inbound";
// 编辑入库单时传入orderId用于排除当前单据自身占用的资产
var orderId = [[${orderId}]];
$(function() {
var url = prefix + "/availableAssetList";
if (orderId) {
url += "?orderId=" + orderId;
}
$.table.init({
url: url,
showSearch: false,
showRefresh: true,
showToggle: false,
showColumns: false,
modalName: "待入库资产",
columns: [{
checkbox: true
},
{
field: "assetCode",
title: "资产编码"
},
{
field: "assetName",
title: "资产名称"
},
{
field: "categoryName",
title: "资产类别"
},
{
field: "specModel",
title: "规格型号"
},
{
field: "brand",
title: "品牌"
},
{
field: "tagCode",
title: "RFID标签"
}]
});
});
/**
* 父窗口回调入口:返回当前勾选的资产行,由入库单页的 addSelectedAssets 接收并写入明细表。
*/
function getSelectedAssets() {
return $("#bootstrap-table").bootstrapTable("getSelections");
}
</script>
</body>
</html>

@ -13,15 +13,10 @@ import java.util.List;
import com.ruoyi.asset.constant.AssetStatus;
import com.ruoyi.asset.domain.AmsAsset;
import com.ruoyi.asset.domain.AmsAssetCategory;
import com.ruoyi.asset.domain.AmsAssetLocation;
import com.ruoyi.asset.domain.AmsWarehouse;
import com.ruoyi.asset.mapper.AmsAssetMapper;
import com.ruoyi.asset.service.IAmsAssetCategoryService;
import com.ruoyi.asset.service.IAmsAssetLocationService;
import com.ruoyi.asset.service.IAmsWarehouseService;
import com.ruoyi.common.core.domain.entity.SysDictData;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.system.service.ISysDeptService;
import com.ruoyi.system.service.ISysDictDataService;
import com.ruoyi.system.service.ISysUserService;
import org.junit.jupiter.api.Test;
@ -40,15 +35,6 @@ class AmsAssetServiceImplTest
@Mock
private IAmsAssetCategoryService amsAssetCategoryService;
@Mock
private IAmsWarehouseService amsWarehouseService;
@Mock
private IAmsAssetLocationService amsAssetLocationService;
@Mock
private ISysDeptService sysDeptService;
@Mock
private ISysUserService sysUserService;
@ -72,9 +58,9 @@ class AmsAssetServiceImplTest
verify(amsAssetMapper).selectBindableAmsAssetList(query, AssetStatus.DISPOSED);
}
/** 新建资产时,无论前端传入什么状态,必须强制为在库并清除使用归属 */
/** 新建资产时,无论前端传入什么状态和归属,必须强制创建为待入库资产 */
@Test
void insertAmsAssetShouldForceStockAndClearUseOwnership()
void insertAmsAssetShouldForcePendingInboundAndClearOwnership()
{
AmsAsset incoming = buildAsset();
incoming.setAssetStatus(AssetStatus.DISPOSED);
@ -90,7 +76,9 @@ class AmsAssetServiceImplTest
ArgumentCaptor<AmsAsset> captor = ArgumentCaptor.forClass(AmsAsset.class);
verify(amsAssetMapper).insertAmsAsset(captor.capture());
assertEquals(AssetStatus.IN_STOCK, captor.getValue().getAssetStatus());
assertEquals(AssetStatus.PENDING_INBOUND, captor.getValue().getAssetStatus());
assertNull(captor.getValue().getWarehouseId());
assertNull(captor.getValue().getLocationId());
assertNull(captor.getValue().getUseDeptId());
assertNull(captor.getValue().getUseUserId());
assertNull(captor.getValue().getTagId());
@ -98,9 +86,9 @@ class AmsAssetServiceImplTest
assertNull(captor.getValue().getEpcCode());
}
/** 有业务引用的资产,编辑时修改归属字段应被拒绝 */
/** 普通编辑提交归属字段时,应忽略前端值并保留数据库当前归属 */
@Test
void updateAmsAssetShouldRejectOwnershipChangeAfterBusinessReference()
void updateAmsAssetShouldPreserveOwnership()
{
AmsAsset current = buildAsset();
current.setUseDeptId(3L);
@ -108,13 +96,16 @@ class AmsAssetServiceImplTest
AmsAsset incoming = buildAsset();
incoming.setWarehouseId(2L);
when(amsAssetMapper.selectAmsAssetByAssetId(1L)).thenReturn(current);
when(amsAssetMapper.countAssetReferences(1L)).thenReturn(1);
when(amsAssetMapper.updateAmsAsset(any(AmsAsset.class))).thenReturn(1);
stubMasterData();
ServiceException exception = assertThrows(ServiceException.class,
() -> service.updateAmsAsset(incoming));
service.updateAmsAsset(incoming);
assertTrue(exception.getMessage().contains("业务流转"));
verify(amsAssetMapper, never()).updateAmsAsset(any(AmsAsset.class));
ArgumentCaptor<AmsAsset> captor = ArgumentCaptor.forClass(AmsAsset.class);
verify(amsAssetMapper).updateAmsAsset(captor.capture());
assertEquals(1L, captor.getValue().getWarehouseId());
assertEquals(3L, captor.getValue().getUseDeptId());
assertEquals(4L, captor.getValue().getUseUserId());
}
/** 未进入业务流程的在库资产编辑时,应保持状态为在库并清除使用归属 */
@ -128,7 +119,6 @@ class AmsAssetServiceImplTest
incoming.setUseDeptId(3L);
incoming.setUseUserId(4L);
when(amsAssetMapper.selectAmsAssetByAssetId(1L)).thenReturn(current);
when(amsAssetMapper.countAssetReferences(1L)).thenReturn(0);
when(amsAssetMapper.updateAmsAsset(any(AmsAsset.class))).thenReturn(1);
stubMasterData();
@ -164,7 +154,6 @@ class AmsAssetServiceImplTest
incoming.setTagCode("TAG-OTHER");
incoming.setEpcCode("EPC-OTHER");
when(amsAssetMapper.selectAmsAssetByAssetId(1L)).thenReturn(current);
when(amsAssetMapper.countAssetReferences(1L)).thenReturn(1);
when(amsAssetMapper.updateAmsAsset(any(AmsAsset.class))).thenReturn(1);
AmsAssetCategory category = new AmsAssetCategory();
category.setCategoryId(1L);
@ -187,9 +176,6 @@ class AmsAssetServiceImplTest
assertEquals(9L, captor.getValue().getTagId());
assertEquals("TAG-001", captor.getValue().getTagCode());
assertEquals("EPC-001", captor.getValue().getEpcCode());
verify(amsWarehouseService, never()).selectAmsWarehouseByWarehouseId(any());
verify(amsAssetLocationService, never()).selectAmsAssetLocationByLocationId(any());
verify(sysDeptService, never()).selectDeptList(any());
verify(sysUserService, never()).selectUserById(any());
}
@ -224,7 +210,7 @@ class AmsAssetServiceImplTest
verify(amsAssetMapper).selectAmsAssetByAssetIdForUpdate(1L);
}
/** 构建常用主数据的 Mock 返回值(类别、仓库、位置、状态字典) */
/** 构建资产基础信息保存所需的类别和状态字典 */
private void stubMasterData()
{
AmsAssetCategory category = new AmsAssetCategory();
@ -232,26 +218,15 @@ class AmsAssetServiceImplTest
category.setCategoryCode("CAT-001");
category.setCategoryName("设备");
category.setEnabled("Y");
AmsWarehouse warehouse = new AmsWarehouse();
warehouse.setWarehouseId(1L);
warehouse.setWarehouseCode("WH-001");
warehouse.setWarehouseName("一号仓");
warehouse.setEnabled("Y");
AmsAssetLocation location = new AmsAssetLocation();
location.setLocationId(10L);
location.setLocationCode("LOC-001");
location.setLocationName("A区");
location.setWarehouseId(1L);
location.setEnabled("Y");
SysDictData status = new SysDictData();
status.setDictValue(AssetStatus.IN_STOCK);
SysDictData pendingStatus = new SysDictData();
pendingStatus.setDictValue(AssetStatus.PENDING_INBOUND);
SysDictData stockStatus = new SysDictData();
stockStatus.setDictValue(AssetStatus.IN_STOCK);
when(amsAssetCategoryService.selectAmsAssetCategoryByCategoryId(1L)).thenReturn(category);
when(amsWarehouseService.selectAmsWarehouseByWarehouseId(1L)).thenReturn(warehouse);
when(amsAssetLocationService.selectAmsAssetLocationByLocationId(10L)).thenReturn(location);
when(sysDictDataService.selectDictDataList(any(SysDictData.class))).thenReturn(List.of(status));
when(sysDictDataService.selectDictDataList(any(SysDictData.class))).thenReturn(List.of(pendingStatus, stockStatus));
}
/** 构建基础的测试资产对象,默认在库状态 */
/** 构建基础的测试资产对象,默认在库状态,具体场景可覆盖为待入库。 */
private AmsAsset buildAsset()
{
AmsAsset asset = new AmsAsset();

@ -7,6 +7,7 @@ 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.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ -21,9 +22,9 @@ import com.ruoyi.asset.domain.AmsInboundOrder;
import com.ruoyi.asset.domain.AmsInboundOrderItem;
import com.ruoyi.asset.domain.AmsWarehouse;
import com.ruoyi.asset.domain.AssetTransitionContext;
import com.ruoyi.asset.mapper.AmsAssetMapper;
import com.ruoyi.asset.mapper.AmsInboundOrderMapper;
import com.ruoyi.asset.service.IAmsAssetLocationService;
import com.ruoyi.asset.service.IAmsAssetService;
import com.ruoyi.asset.service.IAmsWarehouseService;
import com.ruoyi.asset.service.IAssetStatusTransitionService;
import com.ruoyi.common.exception.ServiceException;
@ -41,6 +42,9 @@ class AmsInboundOrderServiceImplTest
@Mock
private AmsInboundOrderMapper amsInboundOrderMapper;
@Mock
private AmsAssetMapper amsAssetMapper;
@Mock
private ISysCodeRuleService sysCodeRuleService;
@ -50,9 +54,6 @@ class AmsInboundOrderServiceImplTest
@Mock
private IAmsAssetLocationService amsAssetLocationService;
@Mock
private IAmsAssetService amsAssetService;
@Mock
private IAssetStatusTransitionService assetStatusTransitionService;
@ -66,6 +67,8 @@ class AmsInboundOrderServiceImplTest
AmsInboundOrder order = buildDraftRequest();
when(sysCodeRuleService.nextCode("INBOUND_ORDER")).thenReturn("RK202606100001");
stubMasterData();
when(amsInboundOrderMapper.countOtherDraftInboundOrderByAssetId(1L, null, InboundOrderStatus.DRAFT))
.thenReturn(0);
doAnswer(invocation -> {
AmsInboundOrder inserted = invocation.getArgument(0);
inserted.setOrderId(100L);
@ -92,6 +95,35 @@ class AmsInboundOrderServiceImplTest
assertNotNull(item.getCreateTime());
}
/** 同一待入库资产被其他有效草稿占用时必须拒绝保存。 */
@Test
void insertShouldRejectAssetReservedByOtherDraft()
{
AmsInboundOrder order = buildDraftRequest();
when(sysCodeRuleService.nextCode("INBOUND_ORDER")).thenReturn("RK202606100001");
stubMasterData();
when(amsInboundOrderMapper.countOtherDraftInboundOrderByAssetId(1L, null, InboundOrderStatus.DRAFT))
.thenReturn(1);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.insertAmsInboundOrder(order));
assertTrue(exception.getMessage().contains("其他草稿"));
verify(amsInboundOrderMapper, never()).insertAmsInboundOrder(any(AmsInboundOrder.class));
}
/** 分页候选查询固定筛选待入库资产并排除其他草稿占用。 */
@Test
void selectAvailableInboundAssetListShouldDelegateFixedStatuses()
{
AmsAsset query = new AmsAsset();
List<AmsAsset> expected = List.of(new AmsAsset());
when(amsInboundOrderMapper.selectAvailableInboundAssetList(query, 100L,
AssetStatus.PENDING_INBOUND, InboundOrderStatus.DRAFT)).thenReturn(expected);
assertEquals(expected, service.selectAvailableInboundAssetList(query, 100L));
}
/** 已完成入库单必须拒绝修改,避免绕过页面直接篡改单据。 */
@Test
void updateShouldRejectCompletedOrder()
@ -191,8 +223,8 @@ class AmsInboundOrderServiceImplTest
asset.setCategoryName("测试类别");
asset.setSpecModel("SPEC-001");
asset.setBrand("测试品牌");
asset.setAssetStatus(AssetStatus.IN_STOCK);
when(amsAssetService.selectAmsAssetByAssetId(1L)).thenReturn(asset);
asset.setAssetStatus(AssetStatus.PENDING_INBOUND);
when(amsAssetMapper.selectAmsAssetByAssetIdForUpdate(1L)).thenReturn(asset);
AmsAssetLocation location = new AmsAssetLocation();
location.setLocationId(20L);
@ -200,6 +232,7 @@ class AmsInboundOrderServiceImplTest
location.setLocationCode("LOC-020");
location.setLocationName("二号仓A区");
location.setEnabled("Y");
when(amsAssetLocationService.selectAmsAssetLocationByLocationId(20L)).thenReturn(location);
// 草稿占用冲突会在位置校验前失败,因此该公共桩允许部分用例不消费。
lenient().when(amsAssetLocationService.selectAmsAssetLocationByLocationId(20L)).thenReturn(location);
}
}

@ -61,7 +61,7 @@ class AssetStatusTransitionServiceImplTest
@Test
void confirmInboundShouldUpdateLocationClearUseOwnershipAndWriteLifecycle()
{
AmsAsset asset = buildAsset(AssetStatus.IN_STOCK);
AmsAsset asset = buildAsset(AssetStatus.PENDING_INBOUND);
asset.setUseDeptId(9L);
asset.setUseDeptName("旧部门");
asset.setUseUserId(8L);
@ -76,7 +76,20 @@ class AssetStatusTransitionServiceImplTest
assertEquals(20L, result.getAfterAsset().getLocationId());
assertNull(result.getAfterAsset().getUseDeptId());
assertNull(result.getAfterAsset().getUseUserId());
assertLifecycle(AssetLifecycleBusinessType.INBOUND, AssetStatus.IN_STOCK, AssetStatus.IN_STOCK);
assertLifecycle(AssetLifecycleBusinessType.INBOUND, AssetStatus.PENDING_INBOUND, AssetStatus.IN_STOCK);
}
/** 已在库资产不得重复入库,换仓必须走调拨业务 */
@Test
void confirmInboundShouldRejectStockAsset()
{
stubLockedAssetWithoutUpdate(buildAsset(AssetStatus.IN_STOCK));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.confirmInbound(1L, 2L, 20L, buildContext()));
assertTrue(exception.getMessage().contains("不允许"));
verify(amsAssetMapper, never()).updateAssetForTransition(any(AmsAsset.class));
}
/** 领用流转:写入使用部门和使用人、状态变为在用 */
@ -329,7 +342,7 @@ class AssetStatusTransitionServiceImplTest
@Test
void transitionShouldRejectLocationOutsideWarehouse()
{
stubLockedAssetWithoutUpdate(buildAsset(AssetStatus.IN_STOCK));
stubLockedAssetWithoutUpdate(buildAsset(AssetStatus.PENDING_INBOUND));
AmsWarehouse warehouse = buildWarehouse(2L);
AmsAssetLocation location = buildLocation(20L, 3L);
when(amsWarehouseService.selectAmsWarehouseByWarehouseId(2L)).thenReturn(warehouse);
@ -346,7 +359,7 @@ class AssetStatusTransitionServiceImplTest
@Test
void transitionShouldRejectInactiveWarehouse()
{
stubLockedAssetWithoutUpdate(buildAsset(AssetStatus.IN_STOCK));
stubLockedAssetWithoutUpdate(buildAsset(AssetStatus.PENDING_INBOUND));
AmsWarehouse warehouse = buildWarehouse(2L);
warehouse.setEnabled("N");
when(amsWarehouseService.selectAmsWarehouseByWarehouseId(2L)).thenReturn(warehouse);

@ -51,7 +51,7 @@ class RfidBindingServiceImplTest
void bindShouldUpdateBothSidesAndWriteLifecycle()
{
AmsRfidTag tag = buildUnboundTag();
AmsAsset asset = buildAsset(AssetStatus.IN_STOCK);
AmsAsset asset = buildAsset(AssetStatus.PENDING_INBOUND);
stubBind(tag, asset);
RfidBindingResult result = service.bind(" ASSET-001 ", " EPC-001 ", buildContext());

Loading…
Cancel
Save