diff --git a/yudao-module-fms/src/main/resources/fms/closing/closing-template-presets.json b/yudao-module-fms/src/main/resources/fms/closing/closing-template-presets.json index 7e410c2..7306831 100644 --- a/yudao-module-fms/src/main/resources/fms/closing/closing-template-presets.json +++ b/yudao-module-fms/src/main/resources/fms/closing/closing-template-presets.json @@ -26,7 +26,7 @@ "sort": 20, "entries": [ { - "subjectCode": "560101", + "subjectCode": "560109", "digest": "报销电话费", "direction": "DEBIT", "amountRatio": 100 diff --git a/yudao-module-fms/src/test/java/cn/iocoder/yudao/module/fms/service/closing/FmsClosingTemplateServiceImplTest.java b/yudao-module-fms/src/test/java/cn/iocoder/yudao/module/fms/service/closing/FmsClosingTemplateServiceImplTest.java index 3ef759e..7f6b8d9 100644 --- a/yudao-module-fms/src/test/java/cn/iocoder/yudao/module/fms/service/closing/FmsClosingTemplateServiceImplTest.java +++ b/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 import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_SCHEME_RATIO_INVALID; import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_TEMPLATE_NOT_EXISTS; import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.CLOSING_TEMPLATE_PRESET_SUBJECT_NOT_EXISTS; +import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; @@ -102,6 +103,69 @@ public class FmsClosingTemplateServiceImplTest extends BaseDbUnitTest { assertEquals(0, closingTemplateMapper.selectCount()); } + /** + * 电话费记入销售费用-通讯费,见 https://github.com/YunaiV/ruoyi-vue-pro/issues/1326 + */ + @Test + public void testInitializeClosingTemplates_presetSubjects() { + // mock 方法 + List subjects = buildPresetSubjects(); + when(subjectService.getSubjectList(1L, null, 10L)).thenReturn(subjects); + + // 调用 + closingTemplateService.initializeClosingTemplates(1L, 10L); + + // 断言:报销电话费,借记销售费用-通讯费,而不是办公用品、差旅费 + List templates = closingTemplateMapper.selectListByAccountSetId(1L); + List telephoneRules = findTemplate(templates, "daily-telephone-expense") + .getSubjectRules(); + assertEquals(2, telephoneRules.size()); + FmsSubjectDO telephoneSubject = findSubject(subjects, telephoneRules.get(0).getSubjectId()); + assertEquals("通讯费", telephoneSubject.getName()); + assertSubjectRule(telephoneRules.get(0), findSubject(subjects, "560109"), FmsDebitCreditDirectionEnum.DEBIT, "100"); + assertSubjectRule(telephoneRules.get(1), findSubject(subjects, "1001"), FmsDebitCreditDirectionEnum.CREDIT, "100"); + // 断言:报销差旅费,仍借记差旅费 + List travelRules = findTemplate(templates, "daily-travel-reimbursement") + .getSubjectRules(); + assertEquals(2, travelRules.size()); + assertSubjectRule(travelRules.get(0), findSubject(subjects, "560107"), FmsDebitCreditDirectionEnum.DEBIT, "100"); + assertSubjectRule(travelRules.get(1), findSubject(subjects, "1001"), FmsDebitCreditDirectionEnum.CREDIT, "100"); + // 断言:支付房租水电费、报销业务招待费用的科目不变 + List rentRules = findTemplate(templates, "daily-rent-utilities") + .getSubjectRules(); + assertEquals(2, rentRules.size()); + assertSubjectRule(rentRules.get(0), findSubject(subjects, "560102"), FmsDebitCreditDirectionEnum.DEBIT, "100"); + assertSubjectRule(rentRules.get(1), findSubject(subjects, "1001"), FmsDebitCreditDirectionEnum.CREDIT, "100"); + List entertainmentRules = findTemplate(templates, "daily-business-entertainment") + .getSubjectRules(); + assertEquals(2, entertainmentRules.size()); + assertSubjectRule(entertainmentRules.get(0), findSubject(subjects, "1001"), FmsDebitCreditDirectionEnum.DEBIT, "100"); + assertSubjectRule(entertainmentRules.get(1), findSubject(subjects, "1002"), FmsDebitCreditDirectionEnum.CREDIT, "100"); + // 断言:计提工资,仍按销售费用-工资 15%、管理费用-工资 85% 分摊 + List payrollRules = findTemplate(templates, "transfer-accrue-payroll") + .getSubjectRules(); + assertEquals(3, payrollRules.size()); + assertSubjectRule(payrollRules.get(0), findSubject(subjects, "560110"), FmsDebitCreditDirectionEnum.DEBIT, "15"); + assertSubjectRule(payrollRules.get(1), findSubject(subjects, "560209"), FmsDebitCreditDirectionEnum.DEBIT, "85"); + assertSubjectRule(payrollRules.get(2), findSubject(subjects, "2211"), FmsDebitCreditDirectionEnum.CREDIT, "100"); + // 断言:办公用品不再被任何预置模板引用 + assertEquals(0L, closingTemplateService.getClosingTemplateCountBySubjectIds(1L, + singletonList(findSubject(subjects, "560101").getId()))); + } + + @Test + public void testInitializeClosingTemplates_telephoneSubjectNotExists() { + // mock 方法:账套缺少通讯费科目 + List subjects = buildPresetSubjects(); + subjects.removeIf(subject -> "560109".equals(subject.getCode())); + when(subjectService.getSubjectList(1L, null, 10L)).thenReturn(subjects); + + // 调用,并断言:不会回退到办公用品、差旅费 + assertServiceException(() -> closingTemplateService.initializeClosingTemplates(1L, 10L), + CLOSING_TEMPLATE_PRESET_SUBJECT_NOT_EXISTS, "daily-telephone-expense", "560109"); + assertEquals(0, closingTemplateMapper.selectCount()); + } + @Test public void testGetClosingTemplateList() { // mock 数据 @@ -215,16 +279,59 @@ public class FmsClosingTemplateServiceImplTest extends BaseDbUnitTest { // ========== 随机对象 ========== + /** + * 构建预置模板引用的科目,编号、编码、名称与系统科目模板一致 + */ private List buildPresetSubjects() { - List subjectCodes = Arrays.asList("1001", "1002", "1122", "1221", "1405", "2202", "2211", - "22210101", "22210107", "222102", "222111", "222117", "5001", "560101", "560102", - "560107", "560110", "560209", "560302", "560303"); - List subjects = new ArrayList<>(); - for (int index = 0; index < subjectCodes.size(); index++) { - subjects.add(new FmsSubjectDO().setId((long) index + 1).setCode(subjectCodes.get(index)) - .setName("科目" + index)); - } - return subjects; + return new ArrayList<>(Arrays.asList( + buildSubject(1331L, "1001", "库存现金"), + buildSubject(1332L, "1002", "银行存款"), + buildSubject(1340L, "1122", "应收账款"), + buildSubject(1344L, "1221", "其他应收款"), + buildSubject(1349L, "1405", "库存商品"), + buildSubject(1377L, "2202", "应付账款"), + buildSubject(1379L, "2211", "应付职工薪酬"), + buildSubject(1382L, "22210101", "进项税额"), + buildSubject(1388L, "22210107", "销项税额"), + buildSubject(1392L, "222102", "未交增值税"), + buildSubject(1401L, "222111", "应交所得税"), + buildSubject(1407L, "222117", "应交城市维护建设税"), + buildSubject(1444L, "5001", "主营业务收入"), + buildSubject(1469L, "560101", "办公用品"), + buildSubject(1470L, "560102", "房租"), + buildSubject(1475L, "560107", "差旅费"), + buildSubject(1477L, "560109", "通讯费"), + buildSubject(1478L, "560110", "工资"), + buildSubject(1495L, "560209", "工资"), + buildSubject(1505L, "560302", "利息"), + buildSubject(1506L, "560303", "手续费"))); + } + + private static FmsSubjectDO buildSubject(Long id, String code, String name) { + return new FmsSubjectDO().setId(id).setCode(code).setName(name); + } + + private static FmsSubjectDO findSubject(List subjects, String code) { + return subjects.stream().filter(subject -> code.equals(subject.getCode())) + .findFirst().orElseThrow(() -> new AssertionError("科目不存在:" + code)); + } + + private static FmsSubjectDO findSubject(List subjects, Long id) { + return subjects.stream().filter(subject -> id.equals(subject.getId())) + .findFirst().orElseThrow(() -> new AssertionError("科目不存在:" + id)); + } + + private static FmsClosingTemplateDO findTemplate(List templates, String presetCode) { + return templates.stream().filter(template -> presetCode.equals(template.getPresetCode())) + .findFirst().orElseThrow(() -> new AssertionError("预置模板不存在:" + presetCode)); + } + + private static void assertSubjectRule(FmsClosingTemplateDO.SubjectRule rule, FmsSubjectDO subject, + FmsDebitCreditDirectionEnum direction, String amountRatio) { + assertEquals(subject.getId(), rule.getSubjectId()); + assertEquals(subject.getCode(), rule.getSubjectCode()); + assertEquals(direction.getType(), rule.getDirection()); + assertEquals(0, new BigDecimal(amountRatio).compareTo(rule.getAmountRatio())); } private List buildClosingTemplateSubjects() { diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBDAO.java index 403237a..5ea097b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBDAO.java +++ b/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 { } @Override + public void deleteProductPropertyTable(Long productId) { + // 设备共用产品表,删除产品表即删除该产品下所有设备的属性数据 + devicePropertyMapper.dropTable(productId); + } + + @Override public void insert(IotDeviceDO device, Map properties, Long ts, Long reportTime) { // 校验属性标识符:在拼接 SQL 之前,避免保留字段、归一化后重名的属性写入 IotStorageFieldUtils.validatePropertyIdentifiers(properties.keySet()); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBMapper.java index bbbb44b..e57cb7b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBMapper.java +++ b/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 { void dropColumn(@Param("productId") Long productId, @Param("columnName") String columnName); + void dropTable(@Param("productId") Long productId); + void insert(@Param("productId") Long productId, @Param("deviceId") String deviceId, @Param("ts") Long ts, diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java index 2f72214..e228325 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java +++ b/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 { void defineProductPropertyTable(Long productId, List propertyThingModels); /** + * 删除产品的属性表(包括其中的历史数据) + * + * 幂等:属性表不存在时,直接成功,便于删除产品失败后重试 + * + * @param productId 产品编号 + */ + void deleteProductPropertyTable(Long productId); + + /** * 写入设备属性。只记录本次上报的属性 * * @param device 设备 diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyMapper.java index e20aa41..00392a8 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyMapper.java +++ b/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 { void alterProductPropertySTableDropField(@Param("productId") Long productId, @Param("field") TDengineTableField field); + void dropProductPropertySTable(@Param("productId") Long productId); + void insert(@Param("device") IotDeviceDO device, @Param("properties") Map properties, @Param("ts") Long ts, diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyTDengineDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyTDengineDAO.java index 4b017f5..1bf370a 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyTDengineDAO.java +++ b/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 { } @Override + public void deleteProductPropertyTable(Long productId) { + // 删除超级表时,TDengine 会一并删除其下的设备子表 device_property_{deviceId} + devicePropertyMapper.dropProductPropertySTable(productId); + } + + @Override public void insert(IotDeviceDO device, Map properties, Long ts, Long reportTime) { // 校验属性标识符:在拼接 SQL 之前,避免保留字段、归一化后重名的属性写入 IotStorageFieldUtils.validatePropertyIdentifiers(properties.keySet()); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java index 24c117d..0db791c 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java +++ b/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 { void defineDevicePropertyData(Long productId); /** + * 删除设备属性数据的结构(产品属性表及其历史数据) + * + * 幂等:属性表不存在时,直接成功 + * + * @param productId 产品编号 + */ + void deleteDevicePropertyData(Long productId); + + /** * 保存设备数据 * * @param device 设备 diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java index dbd09cb..efefb6f 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java +++ b/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 { } @Override + public void deleteDevicePropertyData(Long productId) { + // 删除存储结构:TDengine、IoTDB 的 DDL 差异,由存储 DAO 实现 + devicePropertyDAO.deleteProductPropertyTable(productId); + } + + @Override @SuppressWarnings("PatternVariableCanBeUsed") public void saveDeviceProperty(IotDeviceDO device, IotDeviceMessage message) { if (!(message.getParams() instanceof Map)) { diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java index f184563..8db045e 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java +++ b/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 { throw exception(PRODUCT_DELETE_FAIL_HAS_DEVICE); } - // 2. 删除 + // 2.1 先删除产品属性表,见 https://github.com/YunaiV/ruoyi-vue-pro/issues/1324 + // 说明:时序库 DDL 与 MySQL 无法同一事务。属性表删除失败时直接抛出、保留产品以便重试; + // 属性表删除幂等,若后续 MySQL 删除失败,再次删除产品仍可成功 + devicePropertyDataService.deleteDevicePropertyData(id); + // 2.2 删除产品 productMapper.deleteById(id); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDevicePropertyMapper.xml b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDevicePropertyMapper.xml index 2003c55..d4f457c 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDevicePropertyMapper.xml +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDevicePropertyMapper.xml @@ -45,6 +45,10 @@ DROP COLUMN ${field.field} + + DROP STABLE IF EXISTS product_property_${productId} + + INSERT INTO device_property_${device.id} USING product_property_${device.productId} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDevicePropertyIoTDBMapper.xml b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDevicePropertyIoTDBMapper.xml index 7530c27..6399400 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDevicePropertyIoTDBMapper.xml +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDevicePropertyIoTDBMapper.xml @@ -38,6 +38,10 @@ DROP COLUMN IF EXISTS "${columnName}" + + DROP TABLE IF EXISTS product_property_${productId} + + INSERT INTO product_property_${productId} (time, device_id diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java index e5f97c3..8e677e1 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java +++ b/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 { } @Test + public void testDeleteDevicePropertyData_success() { + // 准备参数 + Long productId = 2L; + + // 调用 + service.deleteDevicePropertyData(productId); + + // 断言:委托给存储 DAO 删除属性表,不依赖 MySQL 产品是否存在 + verify(devicePropertyDAO).deleteProductPropertyTable(eq(productId)); + verifyNoInteractions(productService, thingModelService); + } + + @Test + public void testDeleteDevicePropertyData_failed() { + // 准备参数 + Long productId = 2L; + // mock 方法:存储 DAO 删除失败 + RuntimeException failure = new RuntimeException("drop property table failed"); + doThrow(failure).when(devicePropertyDAO).deleteProductPropertyTable(productId); + + // 调用,并断言:异常原样抛出,由产品删除流程中止 + assertSame(failure, assertThrows(RuntimeException.class, () -> service.deleteDevicePropertyData(productId))); + } + + @Test public void testSaveDeviceProperty_success() { // 准备参数 IotDeviceDO device = buildDevice(); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImplTest.java new file mode 100644 index 0000000..0f03aa1 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImplTest.java @@ -0,0 +1,139 @@ +package cn.iocoder.yudao.module.iot.service.product; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import cn.iocoder.yudao.module.iot.dal.mysql.product.IotProductMapper; +import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.io.Serializable; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link IotProductServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +public class IotProductServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private IotProductServiceImpl productService; + + @Mock + private IotProductMapper productMapper; + @Mock + private IotDeviceService deviceService; + @Mock + private IotDevicePropertyService devicePropertyDataService; + + @Test + public void testDeleteProduct_success() { + // 准备参数 + Long id = 1L; + // mock 方法 + when(productMapper.selectById(id)).thenReturn(buildProduct(id, IotProductStatusEnum.UNPUBLISHED.getStatus())); + when(deviceService.getDeviceCountByProductId(id)).thenReturn(0L); + + // 调用 + productService.deleteProduct(id); + // 断言:先删除属性表,再删除 MySQL 产品 + InOrder inOrder = inOrder(devicePropertyDataService, productMapper); + inOrder.verify(devicePropertyDataService).deleteDevicePropertyData(id); + inOrder.verify(productMapper).deleteById(id); + } + + @Test + public void testDeleteProduct_notExists() { + // 准备参数 + Long id = 1L; + + // 调用,并断言异常 + assertServiceException(() -> productService.deleteProduct(id), PRODUCT_NOT_EXISTS); + // 断言:不删除属性表、产品 + assertNoDelete(); + } + + @Test + public void testDeleteProduct_published() { + // 准备参数 + Long id = 1L; + // mock 方法 + when(productMapper.selectById(id)).thenReturn(buildProduct(id, IotProductStatusEnum.PUBLISHED.getStatus())); + + // 调用,并断言异常 + assertServiceException(() -> productService.deleteProduct(id), PRODUCT_STATUS_NOT_DELETE); + // 断言:不删除属性表、产品 + assertNoDelete(); + verifyNoInteractions(deviceService); + } + + @Test + public void testDeleteProduct_hasDevice() { + // 准备参数 + Long id = 1L; + // mock 方法 + when(productMapper.selectById(id)).thenReturn(buildProduct(id, IotProductStatusEnum.UNPUBLISHED.getStatus())); + when(deviceService.getDeviceCountByProductId(id)).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> productService.deleteProduct(id), PRODUCT_DELETE_FAIL_HAS_DEVICE); + // 断言:不删除属性表、产品 + assertNoDelete(); + } + + @Test + public void testDeleteProduct_deletePropertyTableFailed() { + // 准备参数 + Long id = 1L; + // mock 方法:属性表删除失败 + when(productMapper.selectById(id)).thenReturn(buildProduct(id, IotProductStatusEnum.UNPUBLISHED.getStatus())); + when(deviceService.getDeviceCountByProductId(id)).thenReturn(0L); + RuntimeException failure = new RuntimeException("drop property table failed"); + doThrow(failure).when(devicePropertyDataService).deleteDevicePropertyData(id); + + // 调用,并断言异常 + assertSame(failure, assertThrows(RuntimeException.class, () -> productService.deleteProduct(id))); + // 断言:保留 MySQL 产品,便于重试 + verify(productMapper, never()).deleteById(any(Serializable.class)); + } + + @Test + public void testDeleteProduct_deleteProductFailedThenRetry() { + // 准备参数 + Long id = 1L; + // mock 方法:第一次 MySQL 删除失败,第二次成功 + when(productMapper.selectById(id)).thenReturn(buildProduct(id, IotProductStatusEnum.UNPUBLISHED.getStatus())); + when(deviceService.getDeviceCountByProductId(id)).thenReturn(0L); + RuntimeException failure = new RuntimeException("delete product failed"); + when(productMapper.deleteById(id)).thenThrow(failure).thenReturn(1); + + // 调用:第一次失败 + assertSame(failure, assertThrows(RuntimeException.class, () -> productService.deleteProduct(id))); + // 调用:第二次重试成功 + productService.deleteProduct(id); + // 断言:属性表删除是幂等的,重试时再次调用 + verify(devicePropertyDataService, times(2)).deleteDevicePropertyData(id); + verify(productMapper, times(2)).deleteById(id); + } + + private void assertNoDelete() { + verifyNoInteractions(devicePropertyDataService); + verify(productMapper, never()).deleteById(any(Serializable.class)); + } + + private static IotProductDO buildProduct(Long id, Integer status) { + return IotProductDO.builder().id(id).status(status).build(); + } + +} diff --git a/yudao-module-mall/yudao-module-product/src/test/java/cn/iocoder/yudao/module/product/service/spu/ProductSpuServiceImplTest.java b/yudao-module-mall/yudao-module-product/src/test/java/cn/iocoder/yudao/module/product/service/spu/ProductSpuServiceImplTest.java index 0d69c55..1144b1c 100755 --- a/yudao-module-mall/yudao-module-product/src/test/java/cn/iocoder/yudao/module/product/service/spu/ProductSpuServiceImplTest.java +++ b/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.*; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; -// TODO @芋艿:review 下单元测试 - /** * {@link ProductSpuServiceImpl} 的单元测试类 * * @author 芋道源码 */ -// DONE 芋艿:后续 fix 补充的单测 @Import(ProductSpuServiceImpl.class) public class ProductSpuServiceImplTest extends BaseDbUnitTest { diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java index 233c3f9..5195b04 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java +++ b/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 { // 2.2 无需发货 updateOrderObj.setLogisticsId(0L).setLogisticsNo(""); } - // 执行更新 + // 执行更新:以读取时的状态作为乐观锁条件,避免并发收货、取消后被覆盖 updateOrderObj.setStatus(TradeOrderStatusEnum.DELIVERED.getStatus()).setDeliveryTime(LocalDateTime.now()); int updateCount = tradeOrderMapper.updateByIdAndStatus(order.getId(), order.getStatus(), updateOrderObj); if (updateCount == 0) { @@ -436,14 +436,20 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService { /** * 校验交易订单满足被发货的条件 *

- * 1. 交易订单未发货 + * 1. 交易订单待发货,或已发货(修改物流信息) + * 2. 交易订单未退款 * * @param id 交易订单编号 * @return 交易订单 */ private TradeOrderDO validateOrderDeliverable(Long id) { TradeOrderDO order = validateOrderExists(id); - // 1. 校验订单是否未发货 + // 1.1 校验订单状态:仅待发货、已发货(修改物流信息)可发货,见 https://github.com/YunaiV/ruoyi-vue-pro/issues/1310 + if (!TradeOrderStatusEnum.isUndelivered(order.getStatus()) + && !TradeOrderStatusEnum.isDelivered(order.getStatus())) { + throw exception(ORDER_DELIVERY_FAIL_STATUS_NOT_UNDELIVERED); + } + // 1.2 校验订单是否未退款 if (ObjectUtil.notEqual(TradeOrderRefundStatusEnum.NONE.getStatus(), order.getRefundStatus())) { throw exception(ORDER_DELIVERY_FAIL_REFUND_STATUS_NOT_NONE); } diff --git a/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceTest.java b/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceTest.java index a5f8957..4e427a3 100644 --- a/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceTest.java +++ b/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; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -53,12 +56,15 @@ import java.util.Arrays; import java.util.List; import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.ORDER_DELIVERY_FAIL_REFUND_STATUS_NOT_NONE; +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.ORDER_DELIVERY_FAIL_STATUS_NOT_UNDELIVERED; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; /** * {@link TradeOrderUpdateServiceImpl} 的单元测试类 @@ -78,6 +84,9 @@ public class TradeOrderUpdateServiceTest extends BaseDbUnitTest { @Resource private TradeOrderItemMapper tradeOrderItemMapper; + @Resource + private PlatformTransactionManager transactionManager; + @MockitoBean private TradeNoRedisDAO tradeNoRedisDAO; @MockitoBean @@ -299,6 +308,126 @@ public class TradeOrderUpdateServiceTest extends BaseDbUnitTest { reqBO.getOrderId().equals(1L) && reqBO.getUserId().equals(order.getUserId()))); } + @Test // 已发货的订单,允许修改物流信息 + public void testDeliveryOrder_delivered() { + // mock 数据(TradeOrder) + TradeOrderDO order = randomDeliveryOrder(TradeOrderStatusEnum.DELIVERED.getStatus()); + tradeOrderMapper.insert(order); + // 准备参数 + TradeOrderDeliveryReqVO deliveryReqVO = new TradeOrderDeliveryReqVO().setId(1L) + .setLogisticsId(20L).setLogisticsNo("200"); + // mock 方法(物流公司) + when(deliveryExpressService.validateDeliveryExpress(eq(20L))).thenReturn( + randomPojo(DeliveryExpressDO.class, o -> o.setId(20L))); + + // 调用 + tradeOrderUpdateService.deliveryOrder(deliveryReqVO); + // 断言 + TradeOrderDO dbOrder = tradeOrderMapper.selectById(1L); + assertEquals(TradeOrderStatusEnum.DELIVERED.getStatus(), dbOrder.getStatus()); + assertEquals(20L, dbOrder.getLogisticsId()); + assertEquals("200", dbOrder.getLogisticsNo()); + assertNotNull(dbOrder.getDeliveryTime()); + verify(tradeOrderHandler).afterDeliveryOrder(argThat(o -> o.getId().equals(1L))); + } + + @Test + public void testDeliveryOrder_statusInvalid() { + // 准备参数:待支付、已完成、已取消、无效状态 + List statuses = Arrays.asList(TradeOrderStatusEnum.UNPAID.getStatus(), + TradeOrderStatusEnum.COMPLETED.getStatus(), TradeOrderStatusEnum.CANCELED.getStatus(), 99); + for (int i = 0; i < statuses.size(); i++) { + // mock 数据(TradeOrder) + Long id = i + 1L; + Integer status = statuses.get(i); + tradeOrderMapper.insert(randomDeliveryOrder(status).setId(id)); + TradeOrderDeliveryReqVO deliveryReqVO = new TradeOrderDeliveryReqVO().setId(id) + .setLogisticsId(20L).setLogisticsNo("200"); + + // 调用,并断言异常 + assertServiceException(() -> tradeOrderUpdateService.deliveryOrder(deliveryReqVO), + ORDER_DELIVERY_FAIL_STATUS_NOT_UNDELIVERED); + // 断言:订单未变更 + assertDeliveryOrderUnchanged(id, status); + } + // 断言:前置处理、物流公司、消息都未执行 + verifyNoInteractions(tradeOrderHandler, deliveryExpressService, tradeMessageService, socialClientApi); + } + + @Test + public void testDeliveryOrder_refundStatusNotNone() { + // mock 数据(TradeOrder) + TradeOrderDO order = randomDeliveryOrder(TradeOrderStatusEnum.UNDELIVERED.getStatus()) + .setRefundStatus(TradeOrderRefundStatusEnum.PART.getStatus()); + tradeOrderMapper.insert(order); + // 准备参数 + TradeOrderDeliveryReqVO deliveryReqVO = new TradeOrderDeliveryReqVO().setId(1L) + .setLogisticsId(20L).setLogisticsNo("200"); + + // 调用,并断言异常 + assertServiceException(() -> tradeOrderUpdateService.deliveryOrder(deliveryReqVO), + ORDER_DELIVERY_FAIL_REFUND_STATUS_NOT_NONE); + // 断言 + assertDeliveryOrderUnchanged(1L, TradeOrderStatusEnum.UNDELIVERED.getStatus()); + verifyNoInteractions(tradeOrderHandler, deliveryExpressService, tradeMessageService, socialClientApi); + } + + @Test // 读取订单后,订单被并发取消 + public void testDeliveryOrder_concurrentCancel() { + assertDeliveryOrderConcurrentStatusChanged(TradeOrderStatusEnum.UNDELIVERED.getStatus(), + TradeOrderStatusEnum.CANCELED.getStatus()); + } + + @Test // 读取订单后,订单被并发收货 + public void testDeliveryOrder_concurrentReceive() { + assertDeliveryOrderConcurrentStatusChanged(TradeOrderStatusEnum.DELIVERED.getStatus(), + TradeOrderStatusEnum.COMPLETED.getStatus()); + } + + private void assertDeliveryOrderConcurrentStatusChanged(Integer status, Integer newStatus) { + // mock 数据(TradeOrder) + TradeOrderDO order = randomDeliveryOrder(status); + tradeOrderMapper.insert(order); + // 准备参数 + TradeOrderDeliveryReqVO deliveryReqVO = new TradeOrderDeliveryReqVO().setId(1L) + .setLogisticsId(20L).setLogisticsNo("200"); + // mock 方法(物流公司):校验物流公司时,订单状态被其它事务修改并提交 + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + when(deliveryExpressService.validateDeliveryExpress(eq(20L))).thenAnswer(invocation -> { + transactionTemplate.executeWithoutResult(transactionStatus -> + tradeOrderMapper.updateById(new TradeOrderDO().setId(1L).setStatus(newStatus))); + return randomPojo(DeliveryExpressDO.class, o -> o.setId(20L)); + }); + + // 调用,并断言异常 + assertServiceException(() -> tradeOrderUpdateService.deliveryOrder(deliveryReqVO), + ORDER_DELIVERY_FAIL_STATUS_NOT_UNDELIVERED); + // 断言:保留并发修改后的状态,物流信息未被覆盖 + assertDeliveryOrderUnchanged(1L, newStatus); + // 断言:发货后的消息、后置处理都未执行 + verify(tradeMessageService, never()).sendMessageWhenDeliveryOrder(any()); + verify(socialClientApi, never()).sendWxaSubscribeMessage(any()); + verify(tradeOrderHandler, never()).afterDeliveryOrder(any()); + } + + private void assertDeliveryOrderUnchanged(Long id, Integer status) { + TradeOrderDO dbOrder = tradeOrderMapper.selectById(id); + assertEquals(status, dbOrder.getStatus()); + assertEquals(10L, dbOrder.getLogisticsId()); + assertEquals("100", dbOrder.getLogisticsNo()); + assertEquals(buildTime(2023, 2, 10), dbOrder.getDeliveryTime()); + } + + private static TradeOrderDO randomDeliveryOrder(Integer status) { + return randomPojo(TradeOrderDO.class, o -> { + o.setId(1L).setStatus(status); + o.setLogisticsId(10L).setLogisticsNo("100").setDeliveryTime(buildTime(2023, 2, 10)); + o.setRefundStatus(TradeOrderRefundStatusEnum.NONE.getStatus()); + o.setDeliveryType(DeliveryTypeEnum.EXPRESS.getType()); + }); + } + @Test public void testReceiveOrder() { // mock 数据(TradeOrder)