云
6 小时以前 e09f103259fa602382bb5ce2a03361a6397af46a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
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<Boolean> 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<Boolean> 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>T stub(Class<T> 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);}
    }
}