Commit 88bdc7a2a4148111837d83c1b395f3146240ddf6
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
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 | 29 | import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_SCHEME_RATIO_INVALID; |
| 30 | 30 | import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_TEMPLATE_NOT_EXISTS; |
| 31 | 31 | import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_TEMPLATE_PRESET_SUBJECT_NOT_EXISTS; |
| 32 | +import static java.util.Collections.singletonList; | |
| 32 | 33 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 33 | 34 | import static org.junit.jupiter.api.Assertions.assertNull; |
| 34 | 35 | import static org.mockito.ArgumentMatchers.any; |
| ... | ... | @@ -102,6 +103,69 @@ public class FmsClosingTemplateServiceImplTest extends BaseDbUnitTest { |
| 102 | 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 | 169 | @Test |
| 106 | 170 | public void testGetClosingTemplateList() { |
| 107 | 171 | // mock 数据 |
| ... | ... | @@ -215,16 +279,59 @@ public class FmsClosingTemplateServiceImplTest extends BaseDbUnitTest { |
| 215 | 279 | |
| 216 | 280 | // ========== 随机对象 ========== |
| 217 | 281 | |
| 282 | + /** | |
| 283 | + * 构建预置模板引用的科目,编号、编码、名称与系统科目模板一致 | |
| 284 | + */ | |
| 218 | 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 | 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 | 164 | } |
| 165 | 165 | |
| 166 | 166 | @Override |
| 167 | + public void deleteProductPropertyTable(Long productId) { | |
| 168 | + // 设备共用产品表,删除产品表即删除该产品下所有设备的属性数据 | |
| 169 | + devicePropertyMapper.dropTable(productId); | |
| 170 | + } | |
| 171 | + | |
| 172 | + @Override | |
| 167 | 173 | public void insert(IotDeviceDO device, Map<String, Object> properties, Long ts, Long reportTime) { |
| 168 | 174 | // 校验属性标识符:在拼接 SQL 之前,避免保留字段、归一化后重名的属性写入 |
| 169 | 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 | 41 | void dropColumn(@Param("productId") Long productId, |
| 42 | 42 | @Param("columnName") String columnName); |
| 43 | 43 | |
| 44 | + void dropTable(@Param("productId") Long productId); | |
| 45 | + | |
| 44 | 46 | void insert(@Param("productId") Long productId, |
| 45 | 47 | @Param("deviceId") String deviceId, |
| 46 | 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 | 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 | 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 | 79 | void alterProductPropertySTableDropField(@Param("productId") Long productId, |
| 80 | 80 | @Param("field") TDengineTableField field); |
| 81 | 81 | |
| 82 | + void dropProductPropertySTable(@Param("productId") Long productId); | |
| 83 | + | |
| 82 | 84 | void insert(@Param("device") IotDeviceDO device, |
| 83 | 85 | @Param("properties") Map<String, Object> properties, |
| 84 | 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 | 106 | } |
| 107 | 107 | |
| 108 | 108 | @Override |
| 109 | + public void deleteProductPropertyTable(Long productId) { | |
| 110 | + // 删除超级表时,TDengine 会一并删除其下的设备子表 device_property_{deviceId} | |
| 111 | + devicePropertyMapper.dropProductPropertySTable(productId); | |
| 112 | + } | |
| 113 | + | |
| 114 | + @Override | |
| 109 | 115 | public void insert(IotDeviceDO device, Map<String, Object> properties, Long ts, Long reportTime) { |
| 110 | 116 | // 校验属性标识符:在拼接 SQL 之前,避免保留字段、归一化后重名的属性写入 |
| 111 | 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 | 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 | 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 | 77 | } |
| 78 | 78 | |
| 79 | 79 | @Override |
| 80 | + public void deleteDevicePropertyData(Long productId) { | |
| 81 | + // 删除存储结构:TDengine、IoTDB 的 DDL 差异,由存储 DAO 实现 | |
| 82 | + devicePropertyDAO.deleteProductPropertyTable(productId); | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Override | |
| 80 | 86 | @SuppressWarnings("PatternVariableCanBeUsed") |
| 81 | 87 | public void saveDeviceProperty(IotDeviceDO device, IotDeviceMessage message) { |
| 82 | 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 | 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 | 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 | 45 | DROP COLUMN ${field.field} |
| 46 | 46 | </update> |
| 47 | 47 | |
| 48 | + <update id="dropProductPropertySTable"> | |
| 49 | + DROP STABLE IF EXISTS product_property_${productId} | |
| 50 | + </update> | |
| 51 | + | |
| 48 | 52 | <insert id="insert"> |
| 49 | 53 | INSERT INTO device_property_${device.id} |
| 50 | 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 | 38 | DROP COLUMN IF EXISTS "${columnName}" |
| 39 | 39 | </update> |
| 40 | 40 | |
| 41 | + <update id="dropTable"> | |
| 42 | + DROP TABLE IF EXISTS product_property_${productId} | |
| 43 | + </update> | |
| 44 | + | |
| 41 | 45 | <insert id="insert"> |
| 42 | 46 | INSERT INTO product_property_${productId} (time, device_id |
| 43 | 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 | 213 | } |
| 214 | 214 | |
| 215 | 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 | 241 | public void testSaveDeviceProperty_success() { |
| 217 | 242 | // 准备参数 |
| 218 | 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 | 49 | import static org.mockito.ArgumentMatchers.eq; |
| 50 | 50 | import static org.mockito.Mockito.*; |
| 51 | 51 | |
| 52 | -// TODO @芋艿:review 下单元测试 | |
| 53 | - | |
| 54 | 52 | /** |
| 55 | 53 | * {@link ProductSpuServiceImpl} 的单元测试类 |
| 56 | 54 | * |
| 57 | 55 | * @author 芋道源码 |
| 58 | 56 | */ |
| 59 | -// DONE 芋艿:后续 fix 补充的单测 | |
| 60 | 57 | @Import(ProductSpuServiceImpl.class) |
| 61 | 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 | 394 | // 2.2 无需发货 |
| 395 | 395 | updateOrderObj.setLogisticsId(0L).setLogisticsNo(""); |
| 396 | 396 | } |
| 397 | - // 执行更新 | |
| 397 | + // 执行更新:以读取时的状态作为乐观锁条件,避免并发收货、取消后被覆盖 | |
| 398 | 398 | updateOrderObj.setStatus(TradeOrderStatusEnum.DELIVERED.getStatus()).setDeliveryTime(LocalDateTime.now()); |
| 399 | 399 | int updateCount = tradeOrderMapper.updateByIdAndStatus(order.getId(), order.getStatus(), updateOrderObj); |
| 400 | 400 | if (updateCount == 0) { |
| ... | ... | @@ -436,14 +436,20 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService { |
| 436 | 436 | /** |
| 437 | 437 | * 校验交易订单满足被发货的条件 |
| 438 | 438 | * <p> |
| 439 | - * 1. 交易订单未发货 | |
| 439 | + * 1. 交易订单待发货,或已发货(修改物流信息) | |
| 440 | + * 2. 交易订单未退款 | |
| 440 | 441 | * |
| 441 | 442 | * @param id 交易订单编号 |
| 442 | 443 | * @return 交易订单 |
| 443 | 444 | */ |
| 444 | 445 | private TradeOrderDO validateOrderDeliverable(Long id) { |
| 445 | 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 | 453 | if (ObjectUtil.notEqual(TradeOrderRefundStatusEnum.NONE.getStatus(), order.getRefundStatus())) { |
| 448 | 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 | 45 | import org.springframework.mock.web.MockHttpServletRequest; |
| 46 | 46 | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| 47 | 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 | 51 | import org.springframework.web.context.request.RequestContextHolder; |
| 49 | 52 | import org.springframework.web.context.request.ServletRequestAttributes; |
| 50 | 53 | |
| ... | ... | @@ -53,12 +56,15 @@ import java.util.Arrays; |
| 53 | 56 | import java.util.List; |
| 54 | 57 | |
| 55 | 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 | 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 | 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 | 65 | import static org.junit.jupiter.api.Assertions.*; |
| 59 | 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 | 70 | * {@link TradeOrderUpdateServiceImpl} 的单元测试类 |
| ... | ... | @@ -78,6 +84,9 @@ public class TradeOrderUpdateServiceTest extends BaseDbUnitTest { |
| 78 | 84 | @Resource |
| 79 | 85 | private TradeOrderItemMapper tradeOrderItemMapper; |
| 80 | 86 | |
| 87 | + @Resource | |
| 88 | + private PlatformTransactionManager transactionManager; | |
| 89 | + | |
| 81 | 90 | @MockitoBean |
| 82 | 91 | private TradeNoRedisDAO tradeNoRedisDAO; |
| 83 | 92 | @MockitoBean |
| ... | ... | @@ -299,6 +308,126 @@ public class TradeOrderUpdateServiceTest extends BaseDbUnitTest { |
| 299 | 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 | 431 | @Test |
| 303 | 432 | public void testReceiveOrder() { |
| 304 | 433 | // mock 数据(TradeOrder) | ... | ... |