Commit 88bdc7a2a4148111837d83c1b395f3146240ddf6

Authored by YunaiV
1 parent fdfca502

fix: 修复订单发货状态、IoT 产品清理及电话费科目

补齐发货状态校验,保留已发货物流编辑和读取状态 CAS,增加并发状态变化回归。
通过统一存储 DAO 幂等清理 TDengine、IoTDB 产品属性表,保留删除失败后的重试能力。
将电话费预置模板改为通讯费科目,补齐实际科目回归,并清理已完成的测试注释。

Refs #1310, #1324, #1326
Showing 17 changed files with 474 additions and 19 deletions
yudao-module-fms/src/main/resources/fms/closing/closing-template-presets.json
@@ -26,7 +26,7 @@ @@ -26,7 +26,7 @@
26 "sort": 20, 26 "sort": 20,
27 "entries": [ 27 "entries": [
28 { 28 {
29 - "subjectCode": "560101", 29 + "subjectCode": "560109",
30 "digest": "报销电话费", 30 "digest": "报销电话费",
31 "direction": "DEBIT", 31 "direction": "DEBIT",
32 "amountRatio": 100 32 "amountRatio": 100
yudao-module-fms/src/test/java/cn/iocoder/yudao/module/fms/service/closing/FmsClosingTemplateServiceImplTest.java
@@ -29,6 +29,7 @@ import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServic @@ -29,6 +29,7 @@ import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServic
29 import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_SCHEME_RATIO_INVALID; 29 import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_SCHEME_RATIO_INVALID;
30 import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_TEMPLATE_NOT_EXISTS; 30 import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_TEMPLATE_NOT_EXISTS;
31 import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_TEMPLATE_PRESET_SUBJECT_NOT_EXISTS; 31 import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_TEMPLATE_PRESET_SUBJECT_NOT_EXISTS;
  32 +import static java.util.Collections.singletonList;
32 import static org.junit.jupiter.api.Assertions.assertEquals; 33 import static org.junit.jupiter.api.Assertions.assertEquals;
33 import static org.junit.jupiter.api.Assertions.assertNull; 34 import static org.junit.jupiter.api.Assertions.assertNull;
34 import static org.mockito.ArgumentMatchers.any; 35 import static org.mockito.ArgumentMatchers.any;
@@ -102,6 +103,69 @@ public class FmsClosingTemplateServiceImplTest extends BaseDbUnitTest { @@ -102,6 +103,69 @@ public class FmsClosingTemplateServiceImplTest extends BaseDbUnitTest {
102 assertEquals(0, closingTemplateMapper.selectCount()); 103 assertEquals(0, closingTemplateMapper.selectCount());
103 } 104 }
104 105
  106 + /**
  107 + * 电话费记入销售费用-通讯费,见 https://github.com/YunaiV/ruoyi-vue-pro/issues/1326
  108 + */
  109 + @Test
  110 + public void testInitializeClosingTemplates_presetSubjects() {
  111 + // mock 方法
  112 + List<FmsSubjectDO> subjects = buildPresetSubjects();
  113 + when(subjectService.getSubjectList(1L, null, 10L)).thenReturn(subjects);
  114 +
  115 + // 调用
  116 + closingTemplateService.initializeClosingTemplates(1L, 10L);
  117 +
  118 + // 断言:报销电话费,借记销售费用-通讯费,而不是办公用品、差旅费
  119 + List<FmsClosingTemplateDO> templates = closingTemplateMapper.selectListByAccountSetId(1L);
  120 + List<FmsClosingTemplateDO.SubjectRule> telephoneRules = findTemplate(templates, "daily-telephone-expense")
  121 + .getSubjectRules();
  122 + assertEquals(2, telephoneRules.size());
  123 + FmsSubjectDO telephoneSubject = findSubject(subjects, telephoneRules.get(0).getSubjectId());
  124 + assertEquals("通讯费", telephoneSubject.getName());
  125 + assertSubjectRule(telephoneRules.get(0), findSubject(subjects, "560109"), FmsDebitCreditDirectionEnum.DEBIT, "100");
  126 + assertSubjectRule(telephoneRules.get(1), findSubject(subjects, "1001"), FmsDebitCreditDirectionEnum.CREDIT, "100");
  127 + // 断言:报销差旅费,仍借记差旅费
  128 + List<FmsClosingTemplateDO.SubjectRule> travelRules = findTemplate(templates, "daily-travel-reimbursement")
  129 + .getSubjectRules();
  130 + assertEquals(2, travelRules.size());
  131 + assertSubjectRule(travelRules.get(0), findSubject(subjects, "560107"), FmsDebitCreditDirectionEnum.DEBIT, "100");
  132 + assertSubjectRule(travelRules.get(1), findSubject(subjects, "1001"), FmsDebitCreditDirectionEnum.CREDIT, "100");
  133 + // 断言:支付房租水电费、报销业务招待费用的科目不变
  134 + List<FmsClosingTemplateDO.SubjectRule> rentRules = findTemplate(templates, "daily-rent-utilities")
  135 + .getSubjectRules();
  136 + assertEquals(2, rentRules.size());
  137 + assertSubjectRule(rentRules.get(0), findSubject(subjects, "560102"), FmsDebitCreditDirectionEnum.DEBIT, "100");
  138 + assertSubjectRule(rentRules.get(1), findSubject(subjects, "1001"), FmsDebitCreditDirectionEnum.CREDIT, "100");
  139 + List<FmsClosingTemplateDO.SubjectRule> entertainmentRules = findTemplate(templates, "daily-business-entertainment")
  140 + .getSubjectRules();
  141 + assertEquals(2, entertainmentRules.size());
  142 + assertSubjectRule(entertainmentRules.get(0), findSubject(subjects, "1001"), FmsDebitCreditDirectionEnum.DEBIT, "100");
  143 + assertSubjectRule(entertainmentRules.get(1), findSubject(subjects, "1002"), FmsDebitCreditDirectionEnum.CREDIT, "100");
  144 + // 断言:计提工资,仍按销售费用-工资 15%、管理费用-工资 85% 分摊
  145 + List<FmsClosingTemplateDO.SubjectRule> payrollRules = findTemplate(templates, "transfer-accrue-payroll")
  146 + .getSubjectRules();
  147 + assertEquals(3, payrollRules.size());
  148 + assertSubjectRule(payrollRules.get(0), findSubject(subjects, "560110"), FmsDebitCreditDirectionEnum.DEBIT, "15");
  149 + assertSubjectRule(payrollRules.get(1), findSubject(subjects, "560209"), FmsDebitCreditDirectionEnum.DEBIT, "85");
  150 + assertSubjectRule(payrollRules.get(2), findSubject(subjects, "2211"), FmsDebitCreditDirectionEnum.CREDIT, "100");
  151 + // 断言:办公用品不再被任何预置模板引用
  152 + assertEquals(0L, closingTemplateService.getClosingTemplateCountBySubjectIds(1L,
  153 + singletonList(findSubject(subjects, "560101").getId())));
  154 + }
  155 +
  156 + @Test
  157 + public void testInitializeClosingTemplates_telephoneSubjectNotExists() {
  158 + // mock 方法:账套缺少通讯费科目
  159 + List<FmsSubjectDO> subjects = buildPresetSubjects();
  160 + subjects.removeIf(subject -> "560109".equals(subject.getCode()));
  161 + when(subjectService.getSubjectList(1L, null, 10L)).thenReturn(subjects);
  162 +
  163 + // 调用,并断言:不会回退到办公用品、差旅费
  164 + assertServiceException(() -> closingTemplateService.initializeClosingTemplates(1L, 10L),
  165 + CLOSING_TEMPLATE_PRESET_SUBJECT_NOT_EXISTS, "daily-telephone-expense", "560109");
  166 + assertEquals(0, closingTemplateMapper.selectCount());
  167 + }
  168 +
105 @Test 169 @Test
106 public void testGetClosingTemplateList() { 170 public void testGetClosingTemplateList() {
107 // mock 数据 171 // mock 数据
@@ -215,16 +279,59 @@ public class FmsClosingTemplateServiceImplTest extends BaseDbUnitTest { @@ -215,16 +279,59 @@ public class FmsClosingTemplateServiceImplTest extends BaseDbUnitTest {
215 279
216 // ========== 随机对象 ========== 280 // ========== 随机对象 ==========
217 281
  282 + /**
  283 + * 构建预置模板引用的科目,编号、编码、名称与系统科目模板一致
  284 + */
218 private List<FmsSubjectDO> buildPresetSubjects() { 285 private List<FmsSubjectDO> buildPresetSubjects() {
219 - List<String> subjectCodes = Arrays.asList("1001", "1002", "1122", "1221", "1405", "2202", "2211",  
220 - "22210101", "22210107", "222102", "222111", "222117", "5001", "560101", "560102",  
221 - "560107", "560110", "560209", "560302", "560303");  
222 - List<FmsSubjectDO> subjects = new ArrayList<>();  
223 - for (int index = 0; index < subjectCodes.size(); index++) {  
224 - subjects.add(new FmsSubjectDO().setId((long) index + 1).setCode(subjectCodes.get(index))  
225 - .setName("科目" + index));  
226 - }  
227 - return subjects; 286 + return new ArrayList<>(Arrays.asList(
  287 + buildSubject(1331L, "1001", "库存现金"),
  288 + buildSubject(1332L, "1002", "银行存款"),
  289 + buildSubject(1340L, "1122", "应收账款"),
  290 + buildSubject(1344L, "1221", "其他应收款"),
  291 + buildSubject(1349L, "1405", "库存商品"),
  292 + buildSubject(1377L, "2202", "应付账款"),
  293 + buildSubject(1379L, "2211", "应付职工薪酬"),
  294 + buildSubject(1382L, "22210101", "进项税额"),
  295 + buildSubject(1388L, "22210107", "销项税额"),
  296 + buildSubject(1392L, "222102", "未交增值税"),
  297 + buildSubject(1401L, "222111", "应交所得税"),
  298 + buildSubject(1407L, "222117", "应交城市维护建设税"),
  299 + buildSubject(1444L, "5001", "主营业务收入"),
  300 + buildSubject(1469L, "560101", "办公用品"),
  301 + buildSubject(1470L, "560102", "房租"),
  302 + buildSubject(1475L, "560107", "差旅费"),
  303 + buildSubject(1477L, "560109", "通讯费"),
  304 + buildSubject(1478L, "560110", "工资"),
  305 + buildSubject(1495L, "560209", "工资"),
  306 + buildSubject(1505L, "560302", "利息"),
  307 + buildSubject(1506L, "560303", "手续费")));
  308 + }
  309 +
  310 + private static FmsSubjectDO buildSubject(Long id, String code, String name) {
  311 + return new FmsSubjectDO().setId(id).setCode(code).setName(name);
  312 + }
  313 +
  314 + private static FmsSubjectDO findSubject(List<FmsSubjectDO> subjects, String code) {
  315 + return subjects.stream().filter(subject -> code.equals(subject.getCode()))
  316 + .findFirst().orElseThrow(() -> new AssertionError("科目不存在:" + code));
  317 + }
  318 +
  319 + private static FmsSubjectDO findSubject(List<FmsSubjectDO> subjects, Long id) {
  320 + return subjects.stream().filter(subject -> id.equals(subject.getId()))
  321 + .findFirst().orElseThrow(() -> new AssertionError("科目不存在:" + id));
  322 + }
  323 +
  324 + private static FmsClosingTemplateDO findTemplate(List<FmsClosingTemplateDO> templates, String presetCode) {
  325 + return templates.stream().filter(template -> presetCode.equals(template.getPresetCode()))
  326 + .findFirst().orElseThrow(() -> new AssertionError("预置模板不存在:" + presetCode));
  327 + }
  328 +
  329 + private static void assertSubjectRule(FmsClosingTemplateDO.SubjectRule rule, FmsSubjectDO subject,
  330 + FmsDebitCreditDirectionEnum direction, String amountRatio) {
  331 + assertEquals(subject.getId(), rule.getSubjectId());
  332 + assertEquals(subject.getCode(), rule.getSubjectCode());
  333 + assertEquals(direction.getType(), rule.getDirection());
  334 + assertEquals(0, new BigDecimal(amountRatio).compareTo(rule.getAmountRatio()));
228 } 335 }
229 336
230 private List<FmsSubjectDO> buildClosingTemplateSubjects() { 337 private List<FmsSubjectDO> buildClosingTemplateSubjects() {
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBDAO.java
@@ -164,6 +164,12 @@ public class IotDevicePropertyIoTDBDAO implements IotDevicePropertyDAO { @@ -164,6 +164,12 @@ public class IotDevicePropertyIoTDBDAO implements IotDevicePropertyDAO {
164 } 164 }
165 165
166 @Override 166 @Override
  167 + public void deleteProductPropertyTable(Long productId) {
  168 + // 设备共用产品表,删除产品表即删除该产品下所有设备的属性数据
  169 + devicePropertyMapper.dropTable(productId);
  170 + }
  171 +
  172 + @Override
167 public void insert(IotDeviceDO device, Map<String, Object> properties, Long ts, Long reportTime) { 173 public void insert(IotDeviceDO device, Map<String, Object> properties, Long ts, Long reportTime) {
168 // 校验属性标识符:在拼接 SQL 之前,避免保留字段、归一化后重名的属性写入 174 // 校验属性标识符:在拼接 SQL 之前,避免保留字段、归一化后重名的属性写入
169 IotStorageFieldUtils.validatePropertyIdentifiers(properties.keySet()); 175 IotStorageFieldUtils.validatePropertyIdentifiers(properties.keySet());
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBMapper.java
@@ -41,6 +41,8 @@ public interface IotDevicePropertyIoTDBMapper { @@ -41,6 +41,8 @@ public interface IotDevicePropertyIoTDBMapper {
41 void dropColumn(@Param("productId") Long productId, 41 void dropColumn(@Param("productId") Long productId,
42 @Param("columnName") String columnName); 42 @Param("columnName") String columnName);
43 43
  44 + void dropTable(@Param("productId") Long productId);
  45 +
44 void insert(@Param("productId") Long productId, 46 void insert(@Param("productId") Long productId,
45 @Param("deviceId") String deviceId, 47 @Param("deviceId") String deviceId,
46 @Param("ts") Long ts, 48 @Param("ts") Long ts,
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java
@@ -33,6 +33,15 @@ public interface IotDevicePropertyDAO { @@ -33,6 +33,15 @@ public interface IotDevicePropertyDAO {
33 void defineProductPropertyTable(Long productId, List<IotThingModelDO> propertyThingModels); 33 void defineProductPropertyTable(Long productId, List<IotThingModelDO> propertyThingModels);
34 34
35 /** 35 /**
  36 + * 删除产品的属性表(包括其中的历史数据)
  37 + *
  38 + * 幂等:属性表不存在时,直接成功,便于删除产品失败后重试
  39 + *
  40 + * @param productId 产品编号
  41 + */
  42 + void deleteProductPropertyTable(Long productId);
  43 +
  44 + /**
36 * 写入设备属性。只记录本次上报的属性 45 * 写入设备属性。只记录本次上报的属性
37 * 46 *
38 * @param device 设备 47 * @param device 设备
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyMapper.java
@@ -79,6 +79,8 @@ public interface IotDevicePropertyMapper { @@ -79,6 +79,8 @@ public interface IotDevicePropertyMapper {
79 void alterProductPropertySTableDropField(@Param("productId") Long productId, 79 void alterProductPropertySTableDropField(@Param("productId") Long productId,
80 @Param("field") TDengineTableField field); 80 @Param("field") TDengineTableField field);
81 81
  82 + void dropProductPropertySTable(@Param("productId") Long productId);
  83 +
82 void insert(@Param("device") IotDeviceDO device, 84 void insert(@Param("device") IotDeviceDO device,
83 @Param("properties") Map<String, Object> properties, 85 @Param("properties") Map<String, Object> properties,
84 @Param("ts") Long ts, 86 @Param("ts") Long ts,
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyTDengineDAO.java
@@ -106,6 +106,12 @@ public class IotDevicePropertyTDengineDAO implements IotDevicePropertyDAO { @@ -106,6 +106,12 @@ public class IotDevicePropertyTDengineDAO implements IotDevicePropertyDAO {
106 } 106 }
107 107
108 @Override 108 @Override
  109 + public void deleteProductPropertyTable(Long productId) {
  110 + // 删除超级表时,TDengine 会一并删除其下的设备子表 device_property_{deviceId}
  111 + devicePropertyMapper.dropProductPropertySTable(productId);
  112 + }
  113 +
  114 + @Override
109 public void insert(IotDeviceDO device, Map<String, Object> properties, Long ts, Long reportTime) { 115 public void insert(IotDeviceDO device, Map<String, Object> properties, Long ts, Long reportTime) {
110 // 校验属性标识符:在拼接 SQL 之前,避免保留字段、归一化后重名的属性写入 116 // 校验属性标识符:在拼接 SQL 之前,避免保留字段、归一化后重名的属性写入
111 IotStorageFieldUtils.validatePropertyIdentifiers(properties.keySet()); 117 IotStorageFieldUtils.validatePropertyIdentifiers(properties.keySet());
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java
@@ -29,6 +29,15 @@ public interface IotDevicePropertyService { @@ -29,6 +29,15 @@ public interface IotDevicePropertyService {
29 void defineDevicePropertyData(Long productId); 29 void defineDevicePropertyData(Long productId);
30 30
31 /** 31 /**
  32 + * 删除设备属性数据的结构(产品属性表及其历史数据)
  33 + *
  34 + * 幂等:属性表不存在时,直接成功
  35 + *
  36 + * @param productId 产品编号
  37 + */
  38 + void deleteDevicePropertyData(Long productId);
  39 +
  40 + /**
32 * 保存设备数据 41 * 保存设备数据
33 * 42 *
34 * @param device 设备 43 * @param device 设备
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java
@@ -77,6 +77,12 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { @@ -77,6 +77,12 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
77 } 77 }
78 78
79 @Override 79 @Override
  80 + public void deleteDevicePropertyData(Long productId) {
  81 + // 删除存储结构:TDengine、IoTDB 的 DDL 差异,由存储 DAO 实现
  82 + devicePropertyDAO.deleteProductPropertyTable(productId);
  83 + }
  84 +
  85 + @Override
80 @SuppressWarnings("PatternVariableCanBeUsed") 86 @SuppressWarnings("PatternVariableCanBeUsed")
81 public void saveDeviceProperty(IotDeviceDO device, IotDeviceMessage message) { 87 public void saveDeviceProperty(IotDeviceDO device, IotDeviceMessage message) {
82 if (!(message.getParams() instanceof Map)) { 88 if (!(message.getParams() instanceof Map)) {
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java
@@ -92,7 +92,11 @@ public class IotProductServiceImpl implements IotProductService { @@ -92,7 +92,11 @@ public class IotProductServiceImpl implements IotProductService {
92 throw exception(PRODUCT_DELETE_FAIL_HAS_DEVICE); 92 throw exception(PRODUCT_DELETE_FAIL_HAS_DEVICE);
93 } 93 }
94 94
95 - // 2. 删除 95 + // 2.1 先删除产品属性表,见 https://github.com/YunaiV/ruoyi-vue-pro/issues/1324
  96 + // 说明:时序库 DDL 与 MySQL 无法同一事务。属性表删除失败时直接抛出、保留产品以便重试;
  97 + // 属性表删除幂等,若后续 MySQL 删除失败,再次删除产品仍可成功
  98 + devicePropertyDataService.deleteDevicePropertyData(id);
  99 + // 2.2 删除产品
96 productMapper.deleteById(id); 100 productMapper.deleteById(id);
97 } 101 }
98 102
yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDevicePropertyMapper.xml
@@ -45,6 +45,10 @@ @@ -45,6 +45,10 @@
45 DROP COLUMN ${field.field} 45 DROP COLUMN ${field.field}
46 </update> 46 </update>
47 47
  48 + <update id="dropProductPropertySTable">
  49 + DROP STABLE IF EXISTS product_property_${productId}
  50 + </update>
  51 +
48 <insert id="insert"> 52 <insert id="insert">
49 INSERT INTO device_property_${device.id} 53 INSERT INTO device_property_${device.id}
50 USING product_property_${device.productId} 54 USING product_property_${device.productId}
yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDevicePropertyIoTDBMapper.xml
@@ -38,6 +38,10 @@ @@ -38,6 +38,10 @@
38 DROP COLUMN IF EXISTS "${columnName}" 38 DROP COLUMN IF EXISTS "${columnName}"
39 </update> 39 </update>
40 40
  41 + <update id="dropTable">
  42 + DROP TABLE IF EXISTS product_property_${productId}
  43 + </update>
  44 +
41 <insert id="insert"> 45 <insert id="insert">
42 INSERT INTO product_property_${productId} (time, device_id 46 INSERT INTO product_property_${productId} (time, device_id
43 <if test="reportTime != null"> 47 <if test="reportTime != null">
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java
@@ -213,6 +213,31 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { @@ -213,6 +213,31 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest {
213 } 213 }
214 214
215 @Test 215 @Test
  216 + public void testDeleteDevicePropertyData_success() {
  217 + // 准备参数
  218 + Long productId = 2L;
  219 +
  220 + // 调用
  221 + service.deleteDevicePropertyData(productId);
  222 +
  223 + // 断言:委托给存储 DAO 删除属性表,不依赖 MySQL 产品是否存在
  224 + verify(devicePropertyDAO).deleteProductPropertyTable(eq(productId));
  225 + verifyNoInteractions(productService, thingModelService);
  226 + }
  227 +
  228 + @Test
  229 + public void testDeleteDevicePropertyData_failed() {
  230 + // 准备参数
  231 + Long productId = 2L;
  232 + // mock 方法:存储 DAO 删除失败
  233 + RuntimeException failure = new RuntimeException("drop property table failed");
  234 + doThrow(failure).when(devicePropertyDAO).deleteProductPropertyTable(productId);
  235 +
  236 + // 调用,并断言:异常原样抛出,由产品删除流程中止
  237 + assertSame(failure, assertThrows(RuntimeException.class, () -> service.deleteDevicePropertyData(productId)));
  238 + }
  239 +
  240 + @Test
216 public void testSaveDeviceProperty_success() { 241 public void testSaveDeviceProperty_success() {
217 // 准备参数 242 // 准备参数
218 IotDeviceDO device = buildDevice(); 243 IotDeviceDO device = buildDevice();
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.service.product;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
  5 +import cn.iocoder.yudao.module.iot.dal.mysql.product.IotProductMapper;
  6 +import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum;
  7 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  8 +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
  9 +import org.junit.jupiter.api.Test;
  10 +import org.mockito.InOrder;
  11 +import org.mockito.InjectMocks;
  12 +import org.mockito.Mock;
  13 +
  14 +import java.io.Serializable;
  15 +
  16 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  17 +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*;
  18 +import static org.junit.jupiter.api.Assertions.assertSame;
  19 +import static org.junit.jupiter.api.Assertions.assertThrows;
  20 +import static org.mockito.ArgumentMatchers.any;
  21 +import static org.mockito.Mockito.*;
  22 +
  23 +/**
  24 + * {@link IotProductServiceImpl} 的单元测试
  25 + *
  26 + * @author 芋道源码
  27 + */
  28 +public class IotProductServiceImplTest extends BaseMockitoUnitTest {
  29 +
  30 + @InjectMocks
  31 + private IotProductServiceImpl productService;
  32 +
  33 + @Mock
  34 + private IotProductMapper productMapper;
  35 + @Mock
  36 + private IotDeviceService deviceService;
  37 + @Mock
  38 + private IotDevicePropertyService devicePropertyDataService;
  39 +
  40 + @Test
  41 + public void testDeleteProduct_success() {
  42 + // 准备参数
  43 + Long id = 1L;
  44 + // mock 方法
  45 + when(productMapper.selectById(id)).thenReturn(buildProduct(id, IotProductStatusEnum.UNPUBLISHED.getStatus()));
  46 + when(deviceService.getDeviceCountByProductId(id)).thenReturn(0L);
  47 +
  48 + // 调用
  49 + productService.deleteProduct(id);
  50 + // 断言:先删除属性表,再删除 MySQL 产品
  51 + InOrder inOrder = inOrder(devicePropertyDataService, productMapper);
  52 + inOrder.verify(devicePropertyDataService).deleteDevicePropertyData(id);
  53 + inOrder.verify(productMapper).deleteById(id);
  54 + }
  55 +
  56 + @Test
  57 + public void testDeleteProduct_notExists() {
  58 + // 准备参数
  59 + Long id = 1L;
  60 +
  61 + // 调用,并断言异常
  62 + assertServiceException(() -> productService.deleteProduct(id), PRODUCT_NOT_EXISTS);
  63 + // 断言:不删除属性表、产品
  64 + assertNoDelete();
  65 + }
  66 +
  67 + @Test
  68 + public void testDeleteProduct_published() {
  69 + // 准备参数
  70 + Long id = 1L;
  71 + // mock 方法
  72 + when(productMapper.selectById(id)).thenReturn(buildProduct(id, IotProductStatusEnum.PUBLISHED.getStatus()));
  73 +
  74 + // 调用,并断言异常
  75 + assertServiceException(() -> productService.deleteProduct(id), PRODUCT_STATUS_NOT_DELETE);
  76 + // 断言:不删除属性表、产品
  77 + assertNoDelete();
  78 + verifyNoInteractions(deviceService);
  79 + }
  80 +
  81 + @Test
  82 + public void testDeleteProduct_hasDevice() {
  83 + // 准备参数
  84 + Long id = 1L;
  85 + // mock 方法
  86 + when(productMapper.selectById(id)).thenReturn(buildProduct(id, IotProductStatusEnum.UNPUBLISHED.getStatus()));
  87 + when(deviceService.getDeviceCountByProductId(id)).thenReturn(1L);
  88 +
  89 + // 调用,并断言异常
  90 + assertServiceException(() -> productService.deleteProduct(id), PRODUCT_DELETE_FAIL_HAS_DEVICE);
  91 + // 断言:不删除属性表、产品
  92 + assertNoDelete();
  93 + }
  94 +
  95 + @Test
  96 + public void testDeleteProduct_deletePropertyTableFailed() {
  97 + // 准备参数
  98 + Long id = 1L;
  99 + // mock 方法:属性表删除失败
  100 + when(productMapper.selectById(id)).thenReturn(buildProduct(id, IotProductStatusEnum.UNPUBLISHED.getStatus()));
  101 + when(deviceService.getDeviceCountByProductId(id)).thenReturn(0L);
  102 + RuntimeException failure = new RuntimeException("drop property table failed");
  103 + doThrow(failure).when(devicePropertyDataService).deleteDevicePropertyData(id);
  104 +
  105 + // 调用,并断言异常
  106 + assertSame(failure, assertThrows(RuntimeException.class, () -> productService.deleteProduct(id)));
  107 + // 断言:保留 MySQL 产品,便于重试
  108 + verify(productMapper, never()).deleteById(any(Serializable.class));
  109 + }
  110 +
  111 + @Test
  112 + public void testDeleteProduct_deleteProductFailedThenRetry() {
  113 + // 准备参数
  114 + Long id = 1L;
  115 + // mock 方法:第一次 MySQL 删除失败,第二次成功
  116 + when(productMapper.selectById(id)).thenReturn(buildProduct(id, IotProductStatusEnum.UNPUBLISHED.getStatus()));
  117 + when(deviceService.getDeviceCountByProductId(id)).thenReturn(0L);
  118 + RuntimeException failure = new RuntimeException("delete product failed");
  119 + when(productMapper.deleteById(id)).thenThrow(failure).thenReturn(1);
  120 +
  121 + // 调用:第一次失败
  122 + assertSame(failure, assertThrows(RuntimeException.class, () -> productService.deleteProduct(id)));
  123 + // 调用:第二次重试成功
  124 + productService.deleteProduct(id);
  125 + // 断言:属性表删除是幂等的,重试时再次调用
  126 + verify(devicePropertyDataService, times(2)).deleteDevicePropertyData(id);
  127 + verify(productMapper, times(2)).deleteById(id);
  128 + }
  129 +
  130 + private void assertNoDelete() {
  131 + verifyNoInteractions(devicePropertyDataService);
  132 + verify(productMapper, never()).deleteById(any(Serializable.class));
  133 + }
  134 +
  135 + private static IotProductDO buildProduct(Long id, Integer status) {
  136 + return IotProductDO.builder().id(id).status(status).build();
  137 + }
  138 +
  139 +}
yudao-module-mall/yudao-module-product/src/test/java/cn/iocoder/yudao/module/product/service/spu/ProductSpuServiceImplTest.java
@@ -49,14 +49,11 @@ import static org.junit.jupiter.api.Assertions.*; @@ -49,14 +49,11 @@ import static org.junit.jupiter.api.Assertions.*;
49 import static org.mockito.ArgumentMatchers.eq; 49 import static org.mockito.ArgumentMatchers.eq;
50 import static org.mockito.Mockito.*; 50 import static org.mockito.Mockito.*;
51 51
52 -// TODO @芋艿:review 下单元测试  
53 -  
54 /** 52 /**
55 * {@link ProductSpuServiceImpl} 的单元测试类 53 * {@link ProductSpuServiceImpl} 的单元测试类
56 * 54 *
57 * @author 芋道源码 55 * @author 芋道源码
58 */ 56 */
59 -// DONE 芋艿:后续 fix 补充的单测  
60 @Import(ProductSpuServiceImpl.class) 57 @Import(ProductSpuServiceImpl.class)
61 public class ProductSpuServiceImplTest extends BaseDbUnitTest { 58 public class ProductSpuServiceImplTest extends BaseDbUnitTest {
62 59
yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java
@@ -394,7 +394,7 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService { @@ -394,7 +394,7 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService {
394 // 2.2 无需发货 394 // 2.2 无需发货
395 updateOrderObj.setLogisticsId(0L).setLogisticsNo(""); 395 updateOrderObj.setLogisticsId(0L).setLogisticsNo("");
396 } 396 }
397 - // 执行更新 397 + // 执行更新:以读取时的状态作为乐观锁条件,避免并发收货、取消后被覆盖
398 updateOrderObj.setStatus(TradeOrderStatusEnum.DELIVERED.getStatus()).setDeliveryTime(LocalDateTime.now()); 398 updateOrderObj.setStatus(TradeOrderStatusEnum.DELIVERED.getStatus()).setDeliveryTime(LocalDateTime.now());
399 int updateCount = tradeOrderMapper.updateByIdAndStatus(order.getId(), order.getStatus(), updateOrderObj); 399 int updateCount = tradeOrderMapper.updateByIdAndStatus(order.getId(), order.getStatus(), updateOrderObj);
400 if (updateCount == 0) { 400 if (updateCount == 0) {
@@ -436,14 +436,20 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService { @@ -436,14 +436,20 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService {
436 /** 436 /**
437 * 校验交易订单满足被发货的条件 437 * 校验交易订单满足被发货的条件
438 * <p> 438 * <p>
439 - * 1. 交易订单未发货 439 + * 1. 交易订单待发货,或已发货(修改物流信息)
  440 + * 2. 交易订单未退款
440 * 441 *
441 * @param id 交易订单编号 442 * @param id 交易订单编号
442 * @return 交易订单 443 * @return 交易订单
443 */ 444 */
444 private TradeOrderDO validateOrderDeliverable(Long id) { 445 private TradeOrderDO validateOrderDeliverable(Long id) {
445 TradeOrderDO order = validateOrderExists(id); 446 TradeOrderDO order = validateOrderExists(id);
446 - // 1. 校验订单是否未发货 447 + // 1.1 校验订单状态:仅待发货、已发货(修改物流信息)可发货,见 https://github.com/YunaiV/ruoyi-vue-pro/issues/1310
  448 + if (!TradeOrderStatusEnum.isUndelivered(order.getStatus())
  449 + && !TradeOrderStatusEnum.isDelivered(order.getStatus())) {
  450 + throw exception(ORDER_DELIVERY_FAIL_STATUS_NOT_UNDELIVERED);
  451 + }
  452 + // 1.2 校验订单是否未退款
447 if (ObjectUtil.notEqual(TradeOrderRefundStatusEnum.NONE.getStatus(), order.getRefundStatus())) { 453 if (ObjectUtil.notEqual(TradeOrderRefundStatusEnum.NONE.getStatus(), order.getRefundStatus())) {
448 throw exception(ORDER_DELIVERY_FAIL_REFUND_STATUS_NOT_NONE); 454 throw exception(ORDER_DELIVERY_FAIL_REFUND_STATUS_NOT_NONE);
449 } 455 }
yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceTest.java
@@ -45,6 +45,9 @@ import org.springframework.context.annotation.Import; @@ -45,6 +45,9 @@ import org.springframework.context.annotation.Import;
45 import org.springframework.mock.web.MockHttpServletRequest; 45 import org.springframework.mock.web.MockHttpServletRequest;
46 import org.springframework.test.context.bean.override.mockito.MockitoBean; 46 import org.springframework.test.context.bean.override.mockito.MockitoBean;
47 import org.springframework.test.util.ReflectionTestUtils; 47 import org.springframework.test.util.ReflectionTestUtils;
  48 +import org.springframework.transaction.PlatformTransactionManager;
  49 +import org.springframework.transaction.TransactionDefinition;
  50 +import org.springframework.transaction.support.TransactionTemplate;
48 import org.springframework.web.context.request.RequestContextHolder; 51 import org.springframework.web.context.request.RequestContextHolder;
49 import org.springframework.web.context.request.ServletRequestAttributes; 52 import org.springframework.web.context.request.ServletRequestAttributes;
50 53
@@ -53,12 +56,15 @@ import java.util.Arrays; @@ -53,12 +56,15 @@ import java.util.Arrays;
53 import java.util.List; 56 import java.util.List;
54 57
55 import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet; 58 import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet;
  59 +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime;
56 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals; 60 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals;
  61 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
57 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; 62 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
  63 +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.ORDER_DELIVERY_FAIL_REFUND_STATUS_NOT_NONE;
  64 +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.ORDER_DELIVERY_FAIL_STATUS_NOT_UNDELIVERED;
58 import static org.junit.jupiter.api.Assertions.*; 65 import static org.junit.jupiter.api.Assertions.*;
59 import static org.mockito.ArgumentMatchers.*; 66 import static org.mockito.ArgumentMatchers.*;
60 -import static org.mockito.Mockito.verify;  
61 -import static org.mockito.Mockito.when; 67 +import static org.mockito.Mockito.*;
62 68
63 /** 69 /**
64 * {@link TradeOrderUpdateServiceImpl} 的单元测试类 70 * {@link TradeOrderUpdateServiceImpl} 的单元测试类
@@ -78,6 +84,9 @@ public class TradeOrderUpdateServiceTest extends BaseDbUnitTest { @@ -78,6 +84,9 @@ public class TradeOrderUpdateServiceTest extends BaseDbUnitTest {
78 @Resource 84 @Resource
79 private TradeOrderItemMapper tradeOrderItemMapper; 85 private TradeOrderItemMapper tradeOrderItemMapper;
80 86
  87 + @Resource
  88 + private PlatformTransactionManager transactionManager;
  89 +
81 @MockitoBean 90 @MockitoBean
82 private TradeNoRedisDAO tradeNoRedisDAO; 91 private TradeNoRedisDAO tradeNoRedisDAO;
83 @MockitoBean 92 @MockitoBean
@@ -299,6 +308,126 @@ public class TradeOrderUpdateServiceTest extends BaseDbUnitTest { @@ -299,6 +308,126 @@ public class TradeOrderUpdateServiceTest extends BaseDbUnitTest {
299 reqBO.getOrderId().equals(1L) && reqBO.getUserId().equals(order.getUserId()))); 308 reqBO.getOrderId().equals(1L) && reqBO.getUserId().equals(order.getUserId())));
300 } 309 }
301 310
  311 + @Test // 已发货的订单,允许修改物流信息
  312 + public void testDeliveryOrder_delivered() {
  313 + // mock 数据(TradeOrder)
  314 + TradeOrderDO order = randomDeliveryOrder(TradeOrderStatusEnum.DELIVERED.getStatus());
  315 + tradeOrderMapper.insert(order);
  316 + // 准备参数
  317 + TradeOrderDeliveryReqVO deliveryReqVO = new TradeOrderDeliveryReqVO().setId(1L)
  318 + .setLogisticsId(20L).setLogisticsNo("200");
  319 + // mock 方法(物流公司)
  320 + when(deliveryExpressService.validateDeliveryExpress(eq(20L))).thenReturn(
  321 + randomPojo(DeliveryExpressDO.class, o -> o.setId(20L)));
  322 +
  323 + // 调用
  324 + tradeOrderUpdateService.deliveryOrder(deliveryReqVO);
  325 + // 断言
  326 + TradeOrderDO dbOrder = tradeOrderMapper.selectById(1L);
  327 + assertEquals(TradeOrderStatusEnum.DELIVERED.getStatus(), dbOrder.getStatus());
  328 + assertEquals(20L, dbOrder.getLogisticsId());
  329 + assertEquals("200", dbOrder.getLogisticsNo());
  330 + assertNotNull(dbOrder.getDeliveryTime());
  331 + verify(tradeOrderHandler).afterDeliveryOrder(argThat(o -> o.getId().equals(1L)));
  332 + }
  333 +
  334 + @Test
  335 + public void testDeliveryOrder_statusInvalid() {
  336 + // 准备参数:待支付、已完成、已取消、无效状态
  337 + List<Integer> statuses = Arrays.asList(TradeOrderStatusEnum.UNPAID.getStatus(),
  338 + TradeOrderStatusEnum.COMPLETED.getStatus(), TradeOrderStatusEnum.CANCELED.getStatus(), 99);
  339 + for (int i = 0; i < statuses.size(); i++) {
  340 + // mock 数据(TradeOrder)
  341 + Long id = i + 1L;
  342 + Integer status = statuses.get(i);
  343 + tradeOrderMapper.insert(randomDeliveryOrder(status).setId(id));
  344 + TradeOrderDeliveryReqVO deliveryReqVO = new TradeOrderDeliveryReqVO().setId(id)
  345 + .setLogisticsId(20L).setLogisticsNo("200");
  346 +
  347 + // 调用,并断言异常
  348 + assertServiceException(() -> tradeOrderUpdateService.deliveryOrder(deliveryReqVO),
  349 + ORDER_DELIVERY_FAIL_STATUS_NOT_UNDELIVERED);
  350 + // 断言:订单未变更
  351 + assertDeliveryOrderUnchanged(id, status);
  352 + }
  353 + // 断言:前置处理、物流公司、消息都未执行
  354 + verifyNoInteractions(tradeOrderHandler, deliveryExpressService, tradeMessageService, socialClientApi);
  355 + }
  356 +
  357 + @Test
  358 + public void testDeliveryOrder_refundStatusNotNone() {
  359 + // mock 数据(TradeOrder)
  360 + TradeOrderDO order = randomDeliveryOrder(TradeOrderStatusEnum.UNDELIVERED.getStatus())
  361 + .setRefundStatus(TradeOrderRefundStatusEnum.PART.getStatus());
  362 + tradeOrderMapper.insert(order);
  363 + // 准备参数
  364 + TradeOrderDeliveryReqVO deliveryReqVO = new TradeOrderDeliveryReqVO().setId(1L)
  365 + .setLogisticsId(20L).setLogisticsNo("200");
  366 +
  367 + // 调用,并断言异常
  368 + assertServiceException(() -> tradeOrderUpdateService.deliveryOrder(deliveryReqVO),
  369 + ORDER_DELIVERY_FAIL_REFUND_STATUS_NOT_NONE);
  370 + // 断言
  371 + assertDeliveryOrderUnchanged(1L, TradeOrderStatusEnum.UNDELIVERED.getStatus());
  372 + verifyNoInteractions(tradeOrderHandler, deliveryExpressService, tradeMessageService, socialClientApi);
  373 + }
  374 +
  375 + @Test // 读取订单后,订单被并发取消
  376 + public void testDeliveryOrder_concurrentCancel() {
  377 + assertDeliveryOrderConcurrentStatusChanged(TradeOrderStatusEnum.UNDELIVERED.getStatus(),
  378 + TradeOrderStatusEnum.CANCELED.getStatus());
  379 + }
  380 +
  381 + @Test // 读取订单后,订单被并发收货
  382 + public void testDeliveryOrder_concurrentReceive() {
  383 + assertDeliveryOrderConcurrentStatusChanged(TradeOrderStatusEnum.DELIVERED.getStatus(),
  384 + TradeOrderStatusEnum.COMPLETED.getStatus());
  385 + }
  386 +
  387 + private void assertDeliveryOrderConcurrentStatusChanged(Integer status, Integer newStatus) {
  388 + // mock 数据(TradeOrder)
  389 + TradeOrderDO order = randomDeliveryOrder(status);
  390 + tradeOrderMapper.insert(order);
  391 + // 准备参数
  392 + TradeOrderDeliveryReqVO deliveryReqVO = new TradeOrderDeliveryReqVO().setId(1L)
  393 + .setLogisticsId(20L).setLogisticsNo("200");
  394 + // mock 方法(物流公司):校验物流公司时,订单状态被其它事务修改并提交
  395 + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
  396 + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
  397 + when(deliveryExpressService.validateDeliveryExpress(eq(20L))).thenAnswer(invocation -> {
  398 + transactionTemplate.executeWithoutResult(transactionStatus ->
  399 + tradeOrderMapper.updateById(new TradeOrderDO().setId(1L).setStatus(newStatus)));
  400 + return randomPojo(DeliveryExpressDO.class, o -> o.setId(20L));
  401 + });
  402 +
  403 + // 调用,并断言异常
  404 + assertServiceException(() -> tradeOrderUpdateService.deliveryOrder(deliveryReqVO),
  405 + ORDER_DELIVERY_FAIL_STATUS_NOT_UNDELIVERED);
  406 + // 断言:保留并发修改后的状态,物流信息未被覆盖
  407 + assertDeliveryOrderUnchanged(1L, newStatus);
  408 + // 断言:发货后的消息、后置处理都未执行
  409 + verify(tradeMessageService, never()).sendMessageWhenDeliveryOrder(any());
  410 + verify(socialClientApi, never()).sendWxaSubscribeMessage(any());
  411 + verify(tradeOrderHandler, never()).afterDeliveryOrder(any());
  412 + }
  413 +
  414 + private void assertDeliveryOrderUnchanged(Long id, Integer status) {
  415 + TradeOrderDO dbOrder = tradeOrderMapper.selectById(id);
  416 + assertEquals(status, dbOrder.getStatus());
  417 + assertEquals(10L, dbOrder.getLogisticsId());
  418 + assertEquals("100", dbOrder.getLogisticsNo());
  419 + assertEquals(buildTime(2023, 2, 10), dbOrder.getDeliveryTime());
  420 + }
  421 +
  422 + private static TradeOrderDO randomDeliveryOrder(Integer status) {
  423 + return randomPojo(TradeOrderDO.class, o -> {
  424 + o.setId(1L).setStatus(status);
  425 + o.setLogisticsId(10L).setLogisticsNo("100").setDeliveryTime(buildTime(2023, 2, 10));
  426 + o.setRefundStatus(TradeOrderRefundStatusEnum.NONE.getStatus());
  427 + o.setDeliveryType(DeliveryTypeEnum.EXPRESS.getType());
  428 + });
  429 + }
  430 +
302 @Test 431 @Test
303 public void testReceiveOrder() { 432 public void testReceiveOrder() {
304 // mock 数据(TradeOrder) 433 // mock 数据(TradeOrder)