package cn.iocoder.yudao.module.mes.service.dv.spare; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.module.mes.controller.admin.dv.spare.vo.*; import cn.iocoder.yudao.module.mes.dal.dataobject.dv.spare.*; import cn.iocoder.yudao.module.mes.dal.dataobject.dv.repair.MesDvRepairDO; import cn.iocoder.yudao.module.mes.dal.dataobject.dv.machinery.MesDvMachineryDO; import cn.iocoder.yudao.module.mes.dal.mysql.dv.spare.*; import cn.iocoder.yudao.module.mes.dal.mysql.dv.repair.MesDvRepairMapper; import cn.iocoder.yudao.module.mes.dal.mysql.dv.machinery.MesDvMachineryMapper; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; import org.junit.jupiter.api.Test; import org.springframework.aop.framework.ProxyFactory; import org.springframework.jdbc.core.*; import org.springframework.jdbc.datasource.*; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; import org.springframework.transaction.interceptor.TransactionInterceptor; import java.lang.reflect.*; import java.math.BigDecimal; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.jupiter.api.Assertions.*; class MesDvSpareServiceTest { @Test void openingStockIsRecordedAndMetadataCannotOverwriteStock() { var f=new Fixture(); var req=f.save().setInitialQuantity(new BigDecimal("12")); Long id=f.service.create(req,1L); assertEquals(new BigDecimal("12.000"),f.stock(id)); assertEquals(1,f.count()); req.setId(id).setInitialQuantity(new BigDecimal("999")).setName("New name"); f.service.update(req); assertEquals(new BigDecimal("12.000"),f.stock(id)); req.setUnit("箱"); assertThrows(ServiceException.class,()->f.service.update(req)); } @Test void draftRepairAllowsIssueAndPartialReturnsPreserveTrace() { var f=new Fixture();var issue=f.issue(4); Long id=f.service.issue(issue,1L); assertEquals(new BigDecimal("6.000"),f.stock(1L)); f.service.returnPart(f.action(1).setOriginalId(id),1L); f.service.returnPart(f.action(2).setOriginalId(id),1L); assertEquals(new BigDecimal("9.000"),f.stock(1L)); assertEquals(3,f.count()); assertEquals("R-001",f.jdbc.queryForObject("select repair_code from movement where type=4 limit 1",String.class)); assertThrows(ServiceException.class,()->f.service.returnPart(f.action(2).setOriginalId(id),1L)); assertEquals(3,f.count()); } @Test void insufficientStockAndCompletedRepairCannotIssue() { var f=new Fixture(); assertThrows(ServiceException.class,()->f.service.issue(f.issue(11),1L)); f.jdbc.update("update repair set status=4"); assertThrows(ServiceException.class,()->f.service.issue(f.issue(1),1L)); assertEquals(new BigDecimal("10.000"),f.stock(1L));assertEquals(0,f.count()); } @Test void repeatedRequestDoesNotDoubleDeductAndChangedPayloadIsRejected() { var f=new Fixture();var req=f.issue(2); assertEquals(f.service.issue(req,1L),f.service.issue(req,1L)); assertEquals(new BigDecimal("8.000"),f.stock(1L));assertEquals(1,f.count()); req.setQuantity(BigDecimal.ONE); assertThrows(ServiceException.class,()->f.service.issue(req,1L)); } @Test void receiveAndReturnAreIdempotentAndDisabledPartsStillReturn() { var f=new Fixture();var receive=f.action(3).setSparePartId(1L); assertEquals(f.service.receive(receive,1L),f.service.receive(receive,1L)); var id=f.service.issue(f.issue(2),1L); f.jdbc.update("update part set status=1 where id=1"); assertThrows(ServiceException.class,()->f.service.receive(f.action(1).setSparePartId(1L),1L)); assertThrows(ServiceException.class,()->f.service.issue(f.issue(1),1L)); var back=f.action(2).setOriginalId(id); assertEquals(f.service.returnPart(back,1L),f.service.returnPart(back,1L)); assertEquals(new BigDecimal("13.000"),f.stock(1L));assertEquals(3,f.count()); } @Test void movementFailureRollsBackStockChange() { var f=new Fixture();f.failWrite.set(true); assertThrows(IllegalStateException.class,()->f.service.issue(f.issue(3),1L)); assertEquals(new BigDecimal("10.000"),f.stock(1L));assertEquals(0,f.count()); } @Test void concurrentRequestsCannotOverdrawStock() throws Exception { var f=new Fixture();var start=new CountDownLatch(1); var pool=Executors.newFixedThreadPool(2); try { Callable job=()->{start.await();try{f.service.issue(f.issue(7),1L);return true;}catch(ServiceException e){return false;}}; var a=pool.submit(job);var b=pool.submit(job);start.countDown(); assertNotEquals(a.get(10,TimeUnit.SECONDS),b.get(10,TimeUnit.SECONDS)); assertEquals(new BigDecimal("3.000"),f.stock(1L));assertEquals(1,f.count()); } finally {pool.shutdownNow();} } @Test void concurrentReturnsCannotExceedOriginalQuantity() throws Exception { var f=new Fixture();Long id=f.service.issue(f.issue(4),1L);var start=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(2); try { Callable job=()->{start.await();try{f.service.returnPart(f.action(3).setOriginalId(id),1L);return true;}catch(ServiceException e){return false;}}; var a=pool.submit(job);var b=pool.submit(job);start.countDown(); assertNotEquals(a.get(10,TimeUnit.SECONDS),b.get(10,TimeUnit.SECONDS)); assertEquals(new BigDecimal("9.000"),f.stock(1L));assertEquals(2,f.count()); } finally {pool.shutdownNow();} } @Test void invalidQuantityUserAndHistoryDeletionAreBlocked() { var f=new Fixture(); assertThrows(ServiceException.class,()->f.service.issue(f.issue(0),1L)); assertThrows(ServiceException.class,()->f.service.issue(f.issue(1).setReceiverUserId(9L),1L)); assertThrows(ServiceException.class,()->f.service.delete(1L)); f.service.issue(f.issue(1),1L); f.jdbc.update("update part set stock_quantity=0 where id=1"); assertThrows(ServiceException.class,()->f.service.delete(1L)); } @Test void invalidReturnAndWhitespaceReasonAreBlocked() { var f=new Fixture(); assertThrows(ServiceException.class,()->f.service.returnPart(f.action(1).setOriginalId(99L),1L)); assertThrows(ServiceException.class,()->f.service.issue(f.issue(1).setRemark(" "),1L)); assertEquals(0,f.count()); } @SuppressWarnings("unchecked") static T stub(Class type,InvocationHandler h) { return (T)Proxy.newProxyInstance(type.getClassLoader(),new Class[]{type},h); } static class Fixture { final JdbcTemplate jdbc; final MesDvSpareService service; final AtomicBoolean failWrite=new AtomicBoolean(); Fixture() { var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); jdbc=new JdbcTemplate(ds); jdbc.execute("create table part(id bigint auto_increment primary key,code varchar(64) unique,name varchar(128),unit varchar(32),stock_quantity decimal(18,3),safety_quantity decimal(18,3),status int)"); jdbc.execute("create table repair(id bigint primary key,code varchar(64),machinery_id bigint,status int)"); jdbc.execute("create table movement(id bigint auto_increment primary key,request_key varchar(64) unique,spare_part_id bigint,spare_part_code varchar(64),spare_part_name varchar(128),unit varchar(32),type int,quantity decimal(18,3),before_quantity decimal(18,3),after_quantity decimal(18,3),repair_id bigint,repair_code varchar(64),machinery_id bigint,machinery_name varchar(128),original_id bigint,receiver_user_id bigint,receiver_name varchar(128),operator_user_id bigint,remark varchar(500))"); jdbc.update("insert into part values(1,'S-001','轴承','件',10,2,0)"); jdbc.update("insert into repair values(1,'R-001',1,0)"); var target=new MesDvSpareService(); ReflectionTestUtils.setField(target,"partMapper",stub(MesDvSparePartMapper.class,(p,m,a)->switch(m.getName()) { case "lock" -> onePart((Long)a[0],true); case "selectById" -> onePart((Long)a[0],false); case "selectOne" -> jdbc.query("select * from part where code=?",new BeanPropertyRowMapper<>(MesDvSparePartDO.class),a[1]).stream().findFirst().orElse(null); case "insert" -> {var row=(MesDvSparePartDO)a[0];jdbc.update("insert into part(code,name,unit,stock_quantity,safety_quantity,status)values(?,?,?,?,?,?)",row.getCode(),row.getName(),row.getUnit(),row.getStockQuantity(),row.getSafetyQuantity(),row.getStatus());row.setId(jdbc.queryForObject("select id from part where code=?",Long.class,row.getCode()));yield 1;} case "updateById" -> {var row=(MesDvSparePartDO)a[0];if(row.getCode()!=null)yield jdbc.update("update part set code=?,name=?,unit=?,status=?,safety_quantity=?,stock_quantity=? where id=?",row.getCode(),row.getName(),row.getUnit(),row.getStatus(),row.getSafetyQuantity(),row.getStockQuantity(),row.getId());yield jdbc.update("update part set stock_quantity=? where id=?",row.getStockQuantity(),row.getId());} default -> throw new AssertionError(m.getName()); })); ReflectionTestUtils.setField(target,"movementMapper",stub(MesDvSpareMovementMapper.class,(p,m,a)->switch(m.getName()){ case "byKey" -> jdbc.query("select * from movement where request_key=?",new BeanPropertyRowMapper<>(MesDvSpareMovementDO.class),a[0]).stream().findFirst().orElse(null); case "lock" -> jdbc.query("select * from movement where id=? for update",new BeanPropertyRowMapper<>(MesDvSpareMovementDO.class),a[0]).stream().findFirst().orElse(null); case "returns" -> jdbc.query("select * from movement where type=4",new BeanPropertyRowMapper<>(MesDvSpareMovementDO.class)).stream().filter(row->((Collection)a[0]).contains(row.getOriginalId())).toList(); case "selectCount" -> jdbc.queryForObject("select count(*) from movement where spare_part_id=?",Long.class,a[1]); case "insert" -> {if(failWrite.get())throw new IllegalStateException("simulated write failure");var row=(MesDvSpareMovementDO)a[0];jdbc.update("insert into movement(request_key,spare_part_id,spare_part_code,spare_part_name,unit,type,quantity,before_quantity,after_quantity,repair_id,repair_code,machinery_id,machinery_name,original_id,receiver_user_id,receiver_name,operator_user_id,remark)values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",row.getRequestKey(),row.getSparePartId(),row.getSparePartCode(),row.getSparePartName(),row.getUnit(),row.getType(),row.getQuantity(),row.getBeforeQuantity(),row.getAfterQuantity(),row.getRepairId(),row.getRepairCode(),row.getMachineryId(),row.getMachineryName(),row.getOriginalId(),row.getReceiverUserId(),row.getReceiverName(),row.getOperatorUserId(),row.getRemark());row.setId(jdbc.queryForObject("select id from movement where request_key=?",Long.class,row.getRequestKey()));yield 1;} default -> throw new AssertionError(m.getName()); })); ReflectionTestUtils.setField(target,"repairMapper",stub(MesDvRepairMapper.class,(p,m,a)->jdbc.query("select * from repair where id=? for update",new BeanPropertyRowMapper<>(MesDvRepairDO.class),a[0]).stream().findFirst().orElse(null))); ReflectionTestUtils.setField(target,"machineryMapper",stub(MesDvMachineryMapper.class,(p,m,a)->new MesDvMachineryDO().setId(1L).setName("设备A"))); ReflectionTestUtils.setField(target,"adminUserApi",stub(AdminUserApi.class,(p,m,a)->Objects.equals(a[0],1L)?new AdminUserRespDTO().setId(1L).setNickname("维修员").setStatus(0):null)); var proxy=new ProxyFactory(target); proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource())); service=(MesDvSpareService)proxy.getProxy(); } MesDvSparePartDO onePart(Long id,boolean lock){return jdbc.query("select * from part where id=?"+(lock?" for update":""),new BeanPropertyRowMapper<>(MesDvSparePartDO.class),id).stream().findFirst().orElse(null);} MesDvSparePartSaveReqVO save(){return new MesDvSparePartSaveReqVO().setCode("S-002").setName("皮带").setUnit("件").setInitialQuantity(BigDecimal.ZERO).setSafetyQuantity(BigDecimal.ONE).setStatus(0);} MesDvSpareActionReqVO action(int qty){return new MesDvSpareActionReqVO().setRequestKey(UUID.randomUUID().toString()).setQuantity(BigDecimal.valueOf(qty)).setRemark("维修用途");} MesDvSpareActionReqVO issue(int qty){return action(qty).setSparePartId(1L).setRepairId(1L).setReceiverUserId(1L);} BigDecimal stock(Long id){return jdbc.queryForObject("select stock_quantity from part where id=?",BigDecimal.class,id);} int count(){return jdbc.queryForObject("select count(*) from movement",Integer.class);} } }